chrono_machines 0.8.0 → 0.9.2
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/CHANGELOG.md +26 -0
- data/Cargo.lock +10 -10
- data/Cargo.toml +14 -1
- data/ext/chrono_machines_native/core/Cargo.toml +6 -2
- data/ext/chrono_machines_native/core/src/backoff.rs +132 -64
- data/ext/chrono_machines_native/core/src/dsl.rs +14 -3
- data/ext/chrono_machines_native/core/src/lib.rs +38 -32
- data/ext/chrono_machines_native/core/src/policy.rs +43 -68
- data/ext/chrono_machines_native/core/src/retry.rs +60 -27
- data/ext/chrono_machines_native/core/src/sleep.rs +1 -1
- data/ext/chrono_machines_native/core/src/test_support.rs +16 -0
- data/ext/chrono_machines_native/ffi/Cargo.toml +8 -4
- data/ext/chrono_machines_native/ffi/src/lib.rs +38 -17
- data/lib/chrono_machines/version.rb +1 -1
- metadata +4 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5b11004f5b8e42aefeb73fc0a754d18af5503163e75ba23b2e55f15f767c9b39
|
|
4
|
+
data.tar.gz: af89d525918c2164ef919dbafc840fd8f34c91cf3d97c9ff1067e4f8a07d0d4e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b6eac8b9834568cbe4eef64945fe6c18ef572a808936a280f3b615ae1db8694fa449fa659a042ff5fc97b20d20e94513e33bb367e3cdd55b85595ba4cedf162e
|
|
7
|
+
data.tar.gz: ac0dda8c7a9957e081ed952a9e2aed1d445f5a1ac80f4bf23ba31e07c5ef01fa1b1d54d49ad11df080d27af24da3d4049642b13d9cf5093b193951e3663f00b1
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.9.2](https://github.com/seuros/chrono_machines/compare/chrono_machines/v0.9.1...chrono_machines/v0.9.2) (2026-10-10)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* **ci:** attach release gems with gh release upload ([87466ea](https://github.com/seuros/chrono_machines/commit/87466eafe0126e5473ff49f176180eae08337657))
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Performance Improvements
|
|
12
|
+
|
|
13
|
+
* allocate outside the policy registry lock; adopt clippy pedantic and nursery ([80b40d8](https://github.com/seuros/chrono_machines/commit/80b40d87e5869c74b356a71425639ebf560cec4e))
|
|
14
|
+
|
|
15
|
+
## [0.9.1](https://github.com/seuros/chrono_machines/compare/chrono_machines/v0.9.0...chrono_machines/v0.9.1) (2026-10-05)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
### Bug Fixes
|
|
19
|
+
|
|
20
|
+
* **build:** skip host compile when packaging a cross gem ([5d5b61d](https://github.com/seuros/chrono_machines/commit/5d5b61da68a0815ef3a30f6fb731af844cbd45ee))
|
|
21
|
+
|
|
22
|
+
## [0.9.0](https://github.com/seuros/chrono_machines/compare/chrono_machines/v0.8.0...chrono_machines/v0.9.0) (2026-10-05)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
### Features
|
|
26
|
+
|
|
27
|
+
* require Ruby 4.0, magnus 0.9, Ractor-safe native ext ([316d6f5](https://github.com/seuros/chrono_machines/commit/316d6f5e3de0f58e33d0a124eec4efafefb78cab))
|
|
28
|
+
|
|
3
29
|
## [0.8.0](https://github.com/seuros/chrono_machines/compare/chrono_machines/v0.5.0...chrono_machines/v0.8.0) (2026-09-14)
|
|
4
30
|
|
|
5
31
|
|
data/Cargo.lock
CHANGED
|
@@ -58,9 +58,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
|
|
58
58
|
|
|
59
59
|
[[package]]
|
|
60
60
|
name = "chacha20"
|
|
61
|
-
version = "0.10.
|
|
61
|
+
version = "0.10.2"
|
|
62
62
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
63
|
-
checksum = "
|
|
63
|
+
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
|
|
64
64
|
dependencies = [
|
|
65
65
|
"cfg-if",
|
|
66
66
|
"cpufeatures",
|
|
@@ -69,7 +69,7 @@ dependencies = [
|
|
|
69
69
|
|
|
70
70
|
[[package]]
|
|
71
71
|
name = "chrono-machines"
|
|
72
|
-
version = "0.
|
|
72
|
+
version = "0.8.0"
|
|
73
73
|
dependencies = [
|
|
74
74
|
"rand",
|
|
75
75
|
"tokio",
|
|
@@ -77,7 +77,7 @@ dependencies = [
|
|
|
77
77
|
|
|
78
78
|
[[package]]
|
|
79
79
|
name = "chrono_machines_native"
|
|
80
|
-
version = "0.
|
|
80
|
+
version = "0.9.0"
|
|
81
81
|
dependencies = [
|
|
82
82
|
"chrono-machines",
|
|
83
83
|
"magnus",
|
|
@@ -233,9 +233,9 @@ checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
|
|
|
233
233
|
|
|
234
234
|
[[package]]
|
|
235
235
|
name = "magnus"
|
|
236
|
-
version = "0.
|
|
236
|
+
version = "0.9.2"
|
|
237
237
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
238
|
-
checksum = "
|
|
238
|
+
checksum = "b9068710e00d21be762edca59b9c4c8946dbdb29820d4f76ad559afea07982c9"
|
|
239
239
|
dependencies = [
|
|
240
240
|
"magnus-macros",
|
|
241
241
|
"rb-sys",
|
|
@@ -245,9 +245,9 @@ dependencies = [
|
|
|
245
245
|
|
|
246
246
|
[[package]]
|
|
247
247
|
name = "magnus-macros"
|
|
248
|
-
version = "0.
|
|
248
|
+
version = "0.9.1"
|
|
249
249
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
250
|
-
checksum = "
|
|
250
|
+
checksum = "9dafde2eeb66f090e0f326112323eb8324b12cb32b5d1210e541d3df6b0ce77e"
|
|
251
251
|
dependencies = [
|
|
252
252
|
"proc-macro2",
|
|
253
253
|
"quote",
|
|
@@ -318,9 +318,9 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
|
|
318
318
|
|
|
319
319
|
[[package]]
|
|
320
320
|
name = "rand"
|
|
321
|
-
version = "0.10.
|
|
321
|
+
version = "0.10.3"
|
|
322
322
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
323
|
-
checksum = "
|
|
323
|
+
checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
|
|
324
324
|
dependencies = [
|
|
325
325
|
"chacha20",
|
|
326
326
|
"getrandom",
|
data/Cargo.toml
CHANGED
|
@@ -1,7 +1,20 @@
|
|
|
1
1
|
[workspace]
|
|
2
2
|
members = ["ext/chrono_machines_native/core", "ext/chrono_machines_native/ffi"]
|
|
3
|
-
resolver = "
|
|
3
|
+
resolver = "3"
|
|
4
|
+
|
|
5
|
+
[workspace.package]
|
|
6
|
+
edition = "2024"
|
|
7
|
+
rust-version = "1.99"
|
|
4
8
|
|
|
5
9
|
[workspace.dependencies]
|
|
6
10
|
chrono-machines = { path = "ext/chrono_machines_native/core" }
|
|
7
11
|
rand = { version = "0.10", default-features = false, features = ["std_rng", "sys_rng"] }
|
|
12
|
+
|
|
13
|
+
[workspace.lints.rust]
|
|
14
|
+
unsafe_code = "deny"
|
|
15
|
+
unused_must_use = "warn"
|
|
16
|
+
|
|
17
|
+
[workspace.lints.clippy]
|
|
18
|
+
all = "warn"
|
|
19
|
+
pedantic = "warn"
|
|
20
|
+
nursery = "warn"
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
[package]
|
|
2
2
|
name = "chrono-machines"
|
|
3
|
-
version = "0.8.
|
|
4
|
-
edition =
|
|
3
|
+
version = "0.8.1"
|
|
4
|
+
edition.workspace = true
|
|
5
|
+
rust-version.workspace = true
|
|
5
6
|
authors = ["Abdelkader Boudih <terminale@gmail.com>"]
|
|
6
7
|
license = "MIT"
|
|
7
8
|
description = "Exponential, constant, and Fibonacci backoff retry library with full jitter support - no_std compatible"
|
|
@@ -29,3 +30,6 @@ rand = { version = "0.10", default-features = false, features = ["std_rng"] }
|
|
|
29
30
|
|
|
30
31
|
[dev-dependencies]
|
|
31
32
|
tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros", "time"] }
|
|
33
|
+
|
|
34
|
+
[lints]
|
|
35
|
+
workspace = true
|
|
@@ -8,7 +8,7 @@ use rand::RngExt;
|
|
|
8
8
|
|
|
9
9
|
/// `f64::powi`, implemented by hand so it works in `core` (`no_std`).
|
|
10
10
|
#[inline]
|
|
11
|
-
|
|
11
|
+
fn powi_f64(base: f64, exp: i32) -> f64 {
|
|
12
12
|
if exp == 0 {
|
|
13
13
|
return 1.0;
|
|
14
14
|
}
|
|
@@ -25,7 +25,36 @@ pub(crate) fn powi_f64(base: f64, exp: i32) -> f64 {
|
|
|
25
25
|
acc
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
/// Widen a delay (or Fibonacci factor) to `f64` for the backoff arithmetic.
|
|
29
|
+
///
|
|
30
|
+
/// There is no lossless `u64 -> f64` conversion. Rounding only starts above
|
|
31
|
+
/// 2^53 (2^53 ms is ~285,000 years), so every delay that can actually be slept
|
|
32
|
+
/// converts exactly; larger values only ever lose to the `max_delay_ms` cap.
|
|
33
|
+
#[inline]
|
|
34
|
+
#[expect(
|
|
35
|
+
clippy::cast_precision_loss,
|
|
36
|
+
reason = "inherent to u64 -> f64; exact below 2^53"
|
|
37
|
+
)]
|
|
38
|
+
const fn lossy_f64(n: u64) -> f64 {
|
|
39
|
+
n as f64
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/// Un-jittered exponential delay: `base_delay_ms * multiplier^(attempt - 1)`,
|
|
43
|
+
/// capped at `max_delay_ms`.
|
|
44
|
+
#[inline]
|
|
45
|
+
pub(crate) fn exponential_ms(
|
|
46
|
+
base_delay_ms: u64,
|
|
47
|
+
multiplier: f64,
|
|
48
|
+
max_delay_ms: u64,
|
|
49
|
+
attempt: u8,
|
|
50
|
+
) -> f64 {
|
|
51
|
+
let exponent = i32::from(attempt.saturating_sub(1));
|
|
52
|
+
let base_exponential = lossy_f64(base_delay_ms) * powi_f64(multiplier, exponent);
|
|
53
|
+
base_exponential.min(lossy_f64(max_delay_ms))
|
|
54
|
+
}
|
|
55
|
+
|
|
28
56
|
/// Calculate the nth Fibonacci number (1-indexed): 1, 1, 2, 3, 5, 8, 13, ...
|
|
57
|
+
#[must_use]
|
|
29
58
|
pub fn fibonacci(n: u8) -> u64 {
|
|
30
59
|
match n {
|
|
31
60
|
0 => 0,
|
|
@@ -47,7 +76,22 @@ pub fn fibonacci(n: u8) -> u64 {
|
|
|
47
76
|
///
|
|
48
77
|
/// `jitter_factor` is clamped to `[0.0, 1.0]`: `0.0` returns `base` unchanged,
|
|
49
78
|
/// `1.0` yields a uniform value in `[0, base]`.
|
|
50
|
-
|
|
79
|
+
// Clippy only suggests `mul_add` when `std` provides it; `core` has no
|
|
80
|
+
// `f64::mul_add`, so the suggestion would break the `no_std` build.
|
|
81
|
+
#[cfg_attr(
|
|
82
|
+
feature = "std",
|
|
83
|
+
expect(
|
|
84
|
+
clippy::suboptimal_flops,
|
|
85
|
+
reason = "`f64::mul_add` is std-only (breaks no_std) and would change the rounding"
|
|
86
|
+
)
|
|
87
|
+
)]
|
|
88
|
+
#[expect(
|
|
89
|
+
clippy::cast_possible_truncation,
|
|
90
|
+
clippy::cast_sign_loss,
|
|
91
|
+
reason = "`as` is the saturating float -> int conversion: NaN and negatives \
|
|
92
|
+
become 0, overflow becomes u64::MAX, which is the clamp we want"
|
|
93
|
+
)]
|
|
94
|
+
pub(crate) fn apply_jitter<R: Rng>(base: f64, jitter_factor: f64, rng: &mut R) -> u64 {
|
|
51
95
|
let jitter_factor = jitter_factor.clamp(0.0, 1.0);
|
|
52
96
|
let random_scalar: f64 = rng.random_range(0.0..=1.0);
|
|
53
97
|
let jitter_blend = 1.0 - jitter_factor + random_scalar * jitter_factor;
|
|
@@ -126,7 +170,7 @@ pub trait BackoffStrategy {
|
|
|
126
170
|
|
|
127
171
|
/// Exponential backoff strategy with configurable jitter
|
|
128
172
|
///
|
|
129
|
-
/// Delays grow exponentially:
|
|
173
|
+
/// Delays grow exponentially: `base_delay_ms * multiplier^(attempt - 1)`
|
|
130
174
|
///
|
|
131
175
|
/// # Example
|
|
132
176
|
///
|
|
@@ -156,36 +200,48 @@ pub struct ExponentialBackoff {
|
|
|
156
200
|
|
|
157
201
|
impl ExponentialBackoff {
|
|
158
202
|
/// Create a new exponential backoff builder with default values
|
|
159
|
-
|
|
160
|
-
|
|
203
|
+
#[must_use]
|
|
204
|
+
pub const fn new() -> Self {
|
|
205
|
+
Self {
|
|
206
|
+
max_attempts: 3,
|
|
207
|
+
base_delay_ms: 100,
|
|
208
|
+
multiplier: 2.0,
|
|
209
|
+
max_delay_ms: 10_000,
|
|
210
|
+
jitter_factor: 1.0, // Full jitter by default
|
|
211
|
+
}
|
|
161
212
|
}
|
|
162
213
|
|
|
163
214
|
/// Set the base delay in milliseconds
|
|
164
|
-
|
|
215
|
+
#[must_use]
|
|
216
|
+
pub const fn base_delay_ms(mut self, ms: u64) -> Self {
|
|
165
217
|
self.base_delay_ms = ms;
|
|
166
218
|
self
|
|
167
219
|
}
|
|
168
220
|
|
|
169
221
|
/// Set the exponential multiplier
|
|
170
|
-
|
|
222
|
+
#[must_use]
|
|
223
|
+
pub const fn multiplier(mut self, multiplier: f64) -> Self {
|
|
171
224
|
self.multiplier = multiplier;
|
|
172
225
|
self
|
|
173
226
|
}
|
|
174
227
|
|
|
175
228
|
/// Set the maximum delay cap in milliseconds
|
|
176
|
-
|
|
229
|
+
#[must_use]
|
|
230
|
+
pub const fn max_delay_ms(mut self, ms: u64) -> Self {
|
|
177
231
|
self.max_delay_ms = ms;
|
|
178
232
|
self
|
|
179
233
|
}
|
|
180
234
|
|
|
181
235
|
/// Set the maximum number of attempts
|
|
182
|
-
|
|
236
|
+
#[must_use]
|
|
237
|
+
pub const fn max_attempts(mut self, attempts: u8) -> Self {
|
|
183
238
|
self.max_attempts = attempts;
|
|
184
239
|
self
|
|
185
240
|
}
|
|
186
241
|
|
|
187
242
|
/// Set the jitter factor (0.0 = no jitter, 1.0 = full jitter)
|
|
188
|
-
|
|
243
|
+
#[must_use]
|
|
244
|
+
pub const fn jitter_factor(mut self, factor: f64) -> Self {
|
|
189
245
|
self.jitter_factor = factor.clamp(0.0, 1.0);
|
|
190
246
|
self
|
|
191
247
|
}
|
|
@@ -193,13 +249,7 @@ impl ExponentialBackoff {
|
|
|
193
249
|
|
|
194
250
|
impl Default for ExponentialBackoff {
|
|
195
251
|
fn default() -> Self {
|
|
196
|
-
Self
|
|
197
|
-
max_attempts: 3,
|
|
198
|
-
base_delay_ms: 100,
|
|
199
|
-
multiplier: 2.0,
|
|
200
|
-
max_delay_ms: 10_000,
|
|
201
|
-
jitter_factor: 1.0, // Full jitter by default
|
|
202
|
-
}
|
|
252
|
+
Self::new()
|
|
203
253
|
}
|
|
204
254
|
}
|
|
205
255
|
|
|
@@ -209,9 +259,12 @@ impl BackoffStrategy for ExponentialBackoff {
|
|
|
209
259
|
return None;
|
|
210
260
|
}
|
|
211
261
|
|
|
212
|
-
let
|
|
213
|
-
|
|
214
|
-
|
|
262
|
+
let capped = exponential_ms(
|
|
263
|
+
self.base_delay_ms,
|
|
264
|
+
self.multiplier,
|
|
265
|
+
self.max_delay_ms,
|
|
266
|
+
attempt,
|
|
267
|
+
);
|
|
215
268
|
|
|
216
269
|
Some(apply_jitter(capped, self.jitter_factor, rng))
|
|
217
270
|
}
|
|
@@ -255,24 +308,32 @@ pub struct ConstantBackoff {
|
|
|
255
308
|
|
|
256
309
|
impl ConstantBackoff {
|
|
257
310
|
/// Create a new constant backoff builder with default values
|
|
258
|
-
|
|
259
|
-
|
|
311
|
+
#[must_use]
|
|
312
|
+
pub const fn new() -> Self {
|
|
313
|
+
Self {
|
|
314
|
+
delay_ms: 100,
|
|
315
|
+
max_attempts: 3,
|
|
316
|
+
jitter_factor: 0.0, // No jitter for constant by default
|
|
317
|
+
}
|
|
260
318
|
}
|
|
261
319
|
|
|
262
320
|
/// Set the constant delay in milliseconds
|
|
263
|
-
|
|
321
|
+
#[must_use]
|
|
322
|
+
pub const fn delay_ms(mut self, ms: u64) -> Self {
|
|
264
323
|
self.delay_ms = ms;
|
|
265
324
|
self
|
|
266
325
|
}
|
|
267
326
|
|
|
268
327
|
/// Set the maximum number of attempts
|
|
269
|
-
|
|
328
|
+
#[must_use]
|
|
329
|
+
pub const fn max_attempts(mut self, attempts: u8) -> Self {
|
|
270
330
|
self.max_attempts = attempts;
|
|
271
331
|
self
|
|
272
332
|
}
|
|
273
333
|
|
|
274
334
|
/// Set the jitter factor (0.0 = no jitter, 1.0 = full jitter)
|
|
275
|
-
|
|
335
|
+
#[must_use]
|
|
336
|
+
pub const fn jitter_factor(mut self, factor: f64) -> Self {
|
|
276
337
|
self.jitter_factor = factor.clamp(0.0, 1.0);
|
|
277
338
|
self
|
|
278
339
|
}
|
|
@@ -280,11 +341,7 @@ impl ConstantBackoff {
|
|
|
280
341
|
|
|
281
342
|
impl Default for ConstantBackoff {
|
|
282
343
|
fn default() -> Self {
|
|
283
|
-
Self
|
|
284
|
-
delay_ms: 100,
|
|
285
|
-
max_attempts: 3,
|
|
286
|
-
jitter_factor: 0.0, // No jitter for constant by default
|
|
287
|
-
}
|
|
344
|
+
Self::new()
|
|
288
345
|
}
|
|
289
346
|
}
|
|
290
347
|
|
|
@@ -294,7 +351,11 @@ impl BackoffStrategy for ConstantBackoff {
|
|
|
294
351
|
return None;
|
|
295
352
|
}
|
|
296
353
|
|
|
297
|
-
Some(apply_jitter(
|
|
354
|
+
Some(apply_jitter(
|
|
355
|
+
lossy_f64(self.delay_ms),
|
|
356
|
+
self.jitter_factor,
|
|
357
|
+
rng,
|
|
358
|
+
))
|
|
298
359
|
}
|
|
299
360
|
|
|
300
361
|
fn should_retry(&self, attempt: u8) -> bool {
|
|
@@ -309,7 +370,7 @@ impl BackoffStrategy for ConstantBackoff {
|
|
|
309
370
|
/// Fibonacci backoff strategy
|
|
310
371
|
///
|
|
311
372
|
/// Delays follow the Fibonacci sequence: 1, 1, 2, 3, 5, 8, 13, ...
|
|
312
|
-
/// Each delay is base_delay_ms * fibonacci(attempt)
|
|
373
|
+
/// Each delay is `base_delay_ms * fibonacci(attempt)`.
|
|
313
374
|
///
|
|
314
375
|
/// # Example
|
|
315
376
|
///
|
|
@@ -336,30 +397,40 @@ pub struct FibonacciBackoff {
|
|
|
336
397
|
|
|
337
398
|
impl FibonacciBackoff {
|
|
338
399
|
/// Create a new Fibonacci backoff builder with default values
|
|
339
|
-
|
|
340
|
-
|
|
400
|
+
#[must_use]
|
|
401
|
+
pub const fn new() -> Self {
|
|
402
|
+
Self {
|
|
403
|
+
base_delay_ms: 100,
|
|
404
|
+
max_delay_ms: 10_000,
|
|
405
|
+
max_attempts: 8,
|
|
406
|
+
jitter_factor: 1.0, // Full jitter by default
|
|
407
|
+
}
|
|
341
408
|
}
|
|
342
409
|
|
|
343
410
|
/// Set the base delay in milliseconds
|
|
344
|
-
|
|
411
|
+
#[must_use]
|
|
412
|
+
pub const fn base_delay_ms(mut self, ms: u64) -> Self {
|
|
345
413
|
self.base_delay_ms = ms;
|
|
346
414
|
self
|
|
347
415
|
}
|
|
348
416
|
|
|
349
417
|
/// Set the maximum delay cap in milliseconds
|
|
350
|
-
|
|
418
|
+
#[must_use]
|
|
419
|
+
pub const fn max_delay_ms(mut self, ms: u64) -> Self {
|
|
351
420
|
self.max_delay_ms = ms;
|
|
352
421
|
self
|
|
353
422
|
}
|
|
354
423
|
|
|
355
424
|
/// Set the maximum number of attempts
|
|
356
|
-
|
|
425
|
+
#[must_use]
|
|
426
|
+
pub const fn max_attempts(mut self, attempts: u8) -> Self {
|
|
357
427
|
self.max_attempts = attempts;
|
|
358
428
|
self
|
|
359
429
|
}
|
|
360
430
|
|
|
361
431
|
/// Set the jitter factor (0.0 = no jitter, 1.0 = full jitter)
|
|
362
|
-
|
|
432
|
+
#[must_use]
|
|
433
|
+
pub const fn jitter_factor(mut self, factor: f64) -> Self {
|
|
363
434
|
self.jitter_factor = factor.clamp(0.0, 1.0);
|
|
364
435
|
self
|
|
365
436
|
}
|
|
@@ -367,12 +438,7 @@ impl FibonacciBackoff {
|
|
|
367
438
|
|
|
368
439
|
impl Default for FibonacciBackoff {
|
|
369
440
|
fn default() -> Self {
|
|
370
|
-
Self
|
|
371
|
-
base_delay_ms: 100,
|
|
372
|
-
max_delay_ms: 10_000,
|
|
373
|
-
max_attempts: 8,
|
|
374
|
-
jitter_factor: 1.0, // Full jitter by default
|
|
375
|
-
}
|
|
441
|
+
Self::new()
|
|
376
442
|
}
|
|
377
443
|
}
|
|
378
444
|
|
|
@@ -383,7 +449,8 @@ impl BackoffStrategy for FibonacciBackoff {
|
|
|
383
449
|
}
|
|
384
450
|
|
|
385
451
|
let fib = fibonacci(attempt);
|
|
386
|
-
let base =
|
|
452
|
+
let base =
|
|
453
|
+
(lossy_f64(self.base_delay_ms) * lossy_f64(fib)).min(lossy_f64(self.max_delay_ms));
|
|
387
454
|
|
|
388
455
|
Some(apply_jitter(base, self.jitter_factor, rng))
|
|
389
456
|
}
|
|
@@ -417,11 +484,12 @@ pub enum BackoffPolicy {
|
|
|
417
484
|
|
|
418
485
|
impl BackoffPolicy {
|
|
419
486
|
/// Return the maximum retry attempts for the wrapped strategy.
|
|
420
|
-
|
|
487
|
+
#[must_use]
|
|
488
|
+
pub const fn max_attempts(&self) -> u8 {
|
|
421
489
|
match self {
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
490
|
+
Self::Exponential(policy) => policy.max_attempts,
|
|
491
|
+
Self::Constant(policy) => policy.max_attempts,
|
|
492
|
+
Self::Fibonacci(policy) => policy.max_attempts,
|
|
425
493
|
}
|
|
426
494
|
}
|
|
427
495
|
}
|
|
@@ -429,52 +497,52 @@ impl BackoffPolicy {
|
|
|
429
497
|
impl BackoffStrategy for BackoffPolicy {
|
|
430
498
|
fn delay<R: Rng>(&self, attempt: u8, rng: &mut R) -> Option<u64> {
|
|
431
499
|
match self {
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
500
|
+
Self::Exponential(policy) => policy.delay(attempt, rng),
|
|
501
|
+
Self::Constant(policy) => policy.delay(attempt, rng),
|
|
502
|
+
Self::Fibonacci(policy) => policy.delay(attempt, rng),
|
|
435
503
|
}
|
|
436
504
|
}
|
|
437
505
|
|
|
438
506
|
fn should_retry(&self, attempt: u8) -> bool {
|
|
439
507
|
match self {
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
508
|
+
Self::Exponential(policy) => policy.should_retry(attempt),
|
|
509
|
+
Self::Constant(policy) => policy.should_retry(attempt),
|
|
510
|
+
Self::Fibonacci(policy) => policy.should_retry(attempt),
|
|
443
511
|
}
|
|
444
512
|
}
|
|
445
513
|
|
|
446
514
|
fn max_attempts(&self) -> u8 {
|
|
447
515
|
match self {
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
516
|
+
Self::Exponential(policy) => policy.max_attempts(),
|
|
517
|
+
Self::Constant(policy) => policy.max_attempts(),
|
|
518
|
+
Self::Fibonacci(policy) => policy.max_attempts(),
|
|
451
519
|
}
|
|
452
520
|
}
|
|
453
521
|
|
|
454
522
|
fn max_delay_ms(&self) -> Option<u64> {
|
|
455
523
|
match self {
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
524
|
+
Self::Exponential(policy) => policy.max_delay_ms(),
|
|
525
|
+
Self::Constant(policy) => policy.max_delay_ms(),
|
|
526
|
+
Self::Fibonacci(policy) => policy.max_delay_ms(),
|
|
459
527
|
}
|
|
460
528
|
}
|
|
461
529
|
}
|
|
462
530
|
|
|
463
531
|
impl From<ExponentialBackoff> for BackoffPolicy {
|
|
464
532
|
fn from(value: ExponentialBackoff) -> Self {
|
|
465
|
-
|
|
533
|
+
Self::Exponential(value)
|
|
466
534
|
}
|
|
467
535
|
}
|
|
468
536
|
|
|
469
537
|
impl From<ConstantBackoff> for BackoffPolicy {
|
|
470
538
|
fn from(value: ConstantBackoff) -> Self {
|
|
471
|
-
|
|
539
|
+
Self::Constant(value)
|
|
472
540
|
}
|
|
473
541
|
}
|
|
474
542
|
|
|
475
543
|
impl From<FibonacciBackoff> for BackoffPolicy {
|
|
476
544
|
fn from(value: FibonacciBackoff) -> Self {
|
|
477
|
-
|
|
545
|
+
Self::Fibonacci(value)
|
|
478
546
|
}
|
|
479
547
|
}
|
|
480
548
|
|
|
@@ -22,7 +22,7 @@ pub enum DslError<E> {
|
|
|
22
22
|
|
|
23
23
|
impl<E> From<RetryError<E>> for DslError<E> {
|
|
24
24
|
fn from(value: RetryError<E>) -> Self {
|
|
25
|
-
|
|
25
|
+
Self::Execution(value)
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
28
|
|
|
@@ -32,8 +32,8 @@ where
|
|
|
32
32
|
{
|
|
33
33
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
34
34
|
match self {
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
Self::PolicyMissing(name) => write!(f, "retry policy '{name}' is not registered"),
|
|
36
|
+
Self::Execution(err) => write!(f, "{err}"),
|
|
37
37
|
}
|
|
38
38
|
}
|
|
39
39
|
}
|
|
@@ -41,6 +41,11 @@ where
|
|
|
41
41
|
impl<E> std::error::Error for DslError<E> where E: fmt::Display + std::error::Error {}
|
|
42
42
|
|
|
43
43
|
/// Construct a [`RetryBuilder`] using a named policy from the global registry.
|
|
44
|
+
///
|
|
45
|
+
/// # Errors
|
|
46
|
+
///
|
|
47
|
+
/// Returns [`DslError::PolicyMissing`] when no policy is registered under
|
|
48
|
+
/// `policy_name`.
|
|
44
49
|
pub fn builder_for_policy<F, T, E>(
|
|
45
50
|
policy_name: &str,
|
|
46
51
|
operation: F,
|
|
@@ -55,6 +60,12 @@ where
|
|
|
55
60
|
}
|
|
56
61
|
|
|
57
62
|
/// Execute an operation using a named policy from the global registry.
|
|
63
|
+
///
|
|
64
|
+
/// # Errors
|
|
65
|
+
///
|
|
66
|
+
/// Returns [`DslError::PolicyMissing`] when no policy is registered under
|
|
67
|
+
/// `policy_name`, or [`DslError::Execution`] wrapping the [`RetryError`] when
|
|
68
|
+
/// the retry gives up.
|
|
58
69
|
pub fn retry_with_policy<F, T, E>(
|
|
59
70
|
policy_name: &str,
|
|
60
71
|
operation: F,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
//! ChronoMachines - Pure Rust exponential backoff and retry library
|
|
1
|
+
//! `ChronoMachines` - Pure Rust exponential backoff and retry library
|
|
2
2
|
//!
|
|
3
3
|
//! This crate provides a lightweight, `no_std` compatible implementation of
|
|
4
4
|
//! exponential backoff with full jitter for retry mechanisms.
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
//! # Features
|
|
7
7
|
//!
|
|
8
8
|
//! - **Full Jitter**: Prevents thundering herd problem
|
|
9
|
-
//! -
|
|
9
|
+
//! - **`no_std` compatible**: Works in embedded environments
|
|
10
10
|
//! - **Zero allocation**: Uses stack-only data structures
|
|
11
11
|
//! - **Fast**: Minimal overhead for delay calculations
|
|
12
12
|
//!
|
|
@@ -28,8 +28,6 @@
|
|
|
28
28
|
//! ```
|
|
29
29
|
|
|
30
30
|
#![cfg_attr(not(feature = "std"), no_std)]
|
|
31
|
-
#![warn(rust_2024_compatibility)]
|
|
32
|
-
#![warn(clippy::all)]
|
|
33
31
|
|
|
34
32
|
#[cfg(feature = "alloc")]
|
|
35
33
|
extern crate alloc;
|
|
@@ -92,7 +90,6 @@ pub use sleep::AsyncSleeper;
|
|
|
92
90
|
pub use sleep::StdSleeper;
|
|
93
91
|
pub use sleep::{FnSleeper, Sleeper};
|
|
94
92
|
|
|
95
|
-
use rand::RngExt;
|
|
96
93
|
#[cfg(feature = "std")]
|
|
97
94
|
use rand::rngs::StdRng;
|
|
98
95
|
|
|
@@ -124,8 +121,9 @@ impl Policy {
|
|
|
124
121
|
/// - `max_attempts`: 3
|
|
125
122
|
/// - `base_delay_ms`: 100
|
|
126
123
|
/// - `multiplier`: 2.0
|
|
127
|
-
/// - `max_delay_ms`: 10_000
|
|
128
|
-
|
|
124
|
+
/// - `max_delay_ms`: `10_000`
|
|
125
|
+
#[must_use]
|
|
126
|
+
pub const fn new() -> Self {
|
|
129
127
|
Self {
|
|
130
128
|
max_attempts: 3,
|
|
131
129
|
base_delay_ms: 100,
|
|
@@ -163,6 +161,7 @@ impl Policy {
|
|
|
163
161
|
/// assert!(delay >= 90 && delay <= 100);
|
|
164
162
|
/// ```
|
|
165
163
|
#[cfg(feature = "std")]
|
|
164
|
+
#[must_use]
|
|
166
165
|
pub fn calculate_delay(&self, attempt: u8, jitter_factor: f64) -> u64 {
|
|
167
166
|
let mut rng: StdRng = rand::make_rng();
|
|
168
167
|
self.calculate_delay_with_rng(attempt, jitter_factor, &mut rng)
|
|
@@ -191,31 +190,27 @@ impl Policy {
|
|
|
191
190
|
jitter_factor: f64,
|
|
192
191
|
rng: &mut R,
|
|
193
192
|
) -> u64 {
|
|
194
|
-
//
|
|
195
|
-
|
|
196
|
-
if jitter_factor.is_nan() {
|
|
197
|
-
|
|
193
|
+
// NaN means "full jitter" here; `apply_jitter` clamps everything else
|
|
194
|
+
// into the inclusive range [0.0, 1.0].
|
|
195
|
+
let jitter_factor = if jitter_factor.is_nan() {
|
|
196
|
+
1.0
|
|
198
197
|
} else {
|
|
199
|
-
jitter_factor
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
//
|
|
203
|
-
|
|
204
|
-
let
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
|
|
215
|
-
let jitter_blend = 1.0 - jitter_factor + random_scalar * jitter_factor;
|
|
216
|
-
let jittered = capped * jitter_blend;
|
|
217
|
-
|
|
218
|
-
jittered as u64
|
|
198
|
+
jitter_factor
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
// Same arithmetic as `ExponentialBackoff`: base * multiplier^(attempt-1),
|
|
202
|
+
// capped at max_delay_ms.
|
|
203
|
+
let capped = backoff::exponential_ms(
|
|
204
|
+
self.base_delay_ms,
|
|
205
|
+
self.multiplier,
|
|
206
|
+
self.max_delay_ms,
|
|
207
|
+
attempt,
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
// Blend between deterministic and random delay:
|
|
211
|
+
// base * (1 - jitter_factor + rand * jitter_factor), so 1.0 is full
|
|
212
|
+
// jitter (0 to base), 0.0 is exactly base, and 0.1 gives 90%-100% of base.
|
|
213
|
+
backoff::apply_jitter(capped, jitter_factor, rng)
|
|
219
214
|
}
|
|
220
215
|
|
|
221
216
|
/// Check if another retry should be attempted
|
|
@@ -227,7 +222,8 @@ impl Policy {
|
|
|
227
222
|
/// # Returns
|
|
228
223
|
///
|
|
229
224
|
/// `true` if another retry is allowed, `false` otherwise
|
|
230
|
-
|
|
225
|
+
#[must_use]
|
|
226
|
+
pub const fn should_retry(&self, current_attempt: u8) -> bool {
|
|
231
227
|
current_attempt < self.max_attempts
|
|
232
228
|
}
|
|
233
229
|
}
|
|
@@ -240,3 +236,13 @@ impl Default for Policy {
|
|
|
240
236
|
|
|
241
237
|
#[cfg(test)]
|
|
242
238
|
mod tests;
|
|
239
|
+
|
|
240
|
+
// A bare `cfg(test)` (not `all(test, ..)`) so clippy treats it as test code.
|
|
241
|
+
#[cfg(test)]
|
|
242
|
+
#[cfg(feature = "std")]
|
|
243
|
+
mod test_support;
|
|
244
|
+
|
|
245
|
+
/// Compiles and runs the README's examples as doctests.
|
|
246
|
+
#[cfg(doctest)]
|
|
247
|
+
#[doc = include_str!("../README.md")]
|
|
248
|
+
struct ReadmeDoctests;
|
|
@@ -28,8 +28,11 @@ pub struct PolicyRegistry {
|
|
|
28
28
|
#[cfg(any(feature = "std", feature = "alloc"))]
|
|
29
29
|
impl PolicyRegistry {
|
|
30
30
|
/// Create an empty registry.
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
#[must_use]
|
|
32
|
+
pub const fn new() -> Self {
|
|
33
|
+
Self {
|
|
34
|
+
entries: Vec::new(),
|
|
35
|
+
}
|
|
33
36
|
}
|
|
34
37
|
|
|
35
38
|
/// Insert or replace a policy under the given name.
|
|
@@ -56,6 +59,7 @@ impl PolicyRegistry {
|
|
|
56
59
|
}
|
|
57
60
|
|
|
58
61
|
/// Retrieve a policy by name.
|
|
62
|
+
#[must_use]
|
|
59
63
|
pub fn get(&self, name: &str) -> Option<BackoffPolicy> {
|
|
60
64
|
self.entries
|
|
61
65
|
.iter()
|
|
@@ -79,8 +83,9 @@ impl PolicyRegistry {
|
|
|
79
83
|
}
|
|
80
84
|
|
|
81
85
|
/// Return all registered policies as `(name, policy)` tuples.
|
|
86
|
+
#[must_use]
|
|
82
87
|
pub fn all(&self) -> Vec<(String, BackoffPolicy)> {
|
|
83
|
-
self.entries.
|
|
88
|
+
self.entries.clone()
|
|
84
89
|
}
|
|
85
90
|
|
|
86
91
|
/// Clear the registry.
|
|
@@ -90,12 +95,28 @@ impl PolicyRegistry {
|
|
|
90
95
|
}
|
|
91
96
|
|
|
92
97
|
#[cfg(feature = "std")]
|
|
93
|
-
use std::sync::{
|
|
98
|
+
use std::sync::{PoisonError, RwLock, RwLockReadGuard, RwLockWriteGuard};
|
|
99
|
+
|
|
100
|
+
/// Process-wide registry behind the `*_global_*` functions.
|
|
101
|
+
#[cfg(feature = "std")]
|
|
102
|
+
static GLOBAL_POLICIES: RwLock<PolicyRegistry> = RwLock::new(PolicyRegistry::new());
|
|
103
|
+
|
|
104
|
+
// Poisoning is deliberately ignored by both accessors. No `PolicyRegistry`
|
|
105
|
+
// method can unwind half-way through a mutation, so a lock poisoned by an
|
|
106
|
+
// unrelated panic still guards a consistent registry; refusing service would
|
|
107
|
+
// only turn that one panic into a panic on every later call.
|
|
108
|
+
#[cfg(feature = "std")]
|
|
109
|
+
fn read_global() -> RwLockReadGuard<'static, PolicyRegistry> {
|
|
110
|
+
GLOBAL_POLICIES
|
|
111
|
+
.read()
|
|
112
|
+
.unwrap_or_else(PoisonError::into_inner)
|
|
113
|
+
}
|
|
94
114
|
|
|
95
115
|
#[cfg(feature = "std")]
|
|
96
|
-
fn
|
|
97
|
-
|
|
98
|
-
|
|
116
|
+
fn write_global() -> RwLockWriteGuard<'static, PolicyRegistry> {
|
|
117
|
+
GLOBAL_POLICIES
|
|
118
|
+
.write()
|
|
119
|
+
.unwrap_or_else(PoisonError::into_inner)
|
|
99
120
|
}
|
|
100
121
|
|
|
101
122
|
/// Register a policy in the global registry (requires `std`).
|
|
@@ -104,86 +125,40 @@ pub fn register_global_policy(
|
|
|
104
125
|
name: impl Into<String>,
|
|
105
126
|
policy: BackoffPolicy,
|
|
106
127
|
) -> Option<BackoffPolicy> {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
guard.register(name, policy)
|
|
128
|
+
// Allocate the key before taking the write lock, not inside it.
|
|
129
|
+
let name = name.into();
|
|
130
|
+
write_global().register(name, policy)
|
|
111
131
|
}
|
|
112
132
|
|
|
113
133
|
/// Fetch a policy from the global registry (requires `std`).
|
|
114
134
|
#[cfg(feature = "std")]
|
|
135
|
+
#[must_use]
|
|
115
136
|
pub fn get_global_policy(name: &str) -> Option<BackoffPolicy> {
|
|
116
|
-
|
|
117
|
-
.read()
|
|
118
|
-
.expect("chronomachines global policy registry poisoned");
|
|
119
|
-
guard.get(name)
|
|
137
|
+
read_global().get(name)
|
|
120
138
|
}
|
|
121
139
|
|
|
122
140
|
/// Remove a policy from the global registry (requires `std`).
|
|
123
141
|
#[cfg(feature = "std")]
|
|
142
|
+
#[expect(
|
|
143
|
+
clippy::must_use_candidate,
|
|
144
|
+
reason = "removal is the point; discarding the returned policy is normal use"
|
|
145
|
+
)]
|
|
124
146
|
pub fn remove_global_policy(name: &str) -> Option<BackoffPolicy> {
|
|
125
|
-
|
|
126
|
-
.write()
|
|
127
|
-
.expect("chronomachines global policy registry poisoned");
|
|
128
|
-
guard.remove(name)
|
|
147
|
+
write_global().remove(name)
|
|
129
148
|
}
|
|
130
149
|
|
|
131
150
|
/// List all policies from the global registry (requires `std`).
|
|
132
151
|
#[cfg(feature = "std")]
|
|
152
|
+
#[must_use]
|
|
133
153
|
pub fn list_global_policies() -> Vec<(String, BackoffPolicy)> {
|
|
134
|
-
|
|
135
|
-
.read()
|
|
136
|
-
.expect("chronomachines global policy registry poisoned");
|
|
137
|
-
guard.all()
|
|
154
|
+
read_global().all()
|
|
138
155
|
}
|
|
139
156
|
|
|
140
157
|
/// Clear all entries from the global registry (requires `std`).
|
|
141
158
|
#[cfg(feature = "std")]
|
|
142
159
|
pub fn clear_global_policies() {
|
|
143
|
-
|
|
144
|
-
.write()
|
|
145
|
-
.expect("chronomachines global policy registry poisoned");
|
|
146
|
-
guard.clear();
|
|
160
|
+
write_global().clear();
|
|
147
161
|
}
|
|
148
162
|
|
|
149
|
-
#[cfg(
|
|
150
|
-
mod tests
|
|
151
|
-
use super::*;
|
|
152
|
-
use crate::backoff::{BackoffPolicy, ExponentialBackoff};
|
|
153
|
-
|
|
154
|
-
#[test]
|
|
155
|
-
fn test_registry_crud() {
|
|
156
|
-
let mut registry = PolicyRegistry::new();
|
|
157
|
-
assert!(registry.get("missing").is_none());
|
|
158
|
-
|
|
159
|
-
let policy = BackoffPolicy::from(ExponentialBackoff::new().max_attempts(5));
|
|
160
|
-
assert!(registry.register("api", policy).is_none());
|
|
161
|
-
assert_eq!(registry.get("api").unwrap().max_attempts(), 5);
|
|
162
|
-
|
|
163
|
-
let new_policy = BackoffPolicy::from(ExponentialBackoff::new().max_attempts(3));
|
|
164
|
-
let replaced = registry.register("api", new_policy);
|
|
165
|
-
assert_eq!(replaced.unwrap().max_attempts(), 5);
|
|
166
|
-
assert_eq!(registry.get("api").unwrap().max_attempts(), 3);
|
|
167
|
-
|
|
168
|
-
let removed = registry.remove("api");
|
|
169
|
-
assert!(removed.is_some());
|
|
170
|
-
assert!(registry.get("api").is_none());
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
#[cfg(feature = "std")]
|
|
174
|
-
#[test]
|
|
175
|
-
fn test_global_registry_roundtrip() {
|
|
176
|
-
clear_global_policies();
|
|
177
|
-
assert!(list_global_policies().is_empty());
|
|
178
|
-
|
|
179
|
-
let policy = BackoffPolicy::from(ExponentialBackoff::new().max_attempts(4));
|
|
180
|
-
assert!(register_global_policy("workers", policy).is_none());
|
|
181
|
-
|
|
182
|
-
let fetched = get_global_policy("workers").unwrap();
|
|
183
|
-
assert_eq!(fetched.max_attempts(), 4);
|
|
184
|
-
|
|
185
|
-
let removed = remove_global_policy("workers").unwrap();
|
|
186
|
-
assert_eq!(removed.max_attempts(), 4);
|
|
187
|
-
assert!(get_global_policy("workers").is_none());
|
|
188
|
-
}
|
|
189
|
-
}
|
|
163
|
+
#[cfg(test)]
|
|
164
|
+
mod tests;
|
|
@@ -25,7 +25,7 @@ type NotifyCallback<E> = Box<dyn FnMut(&RetryContext<E>) + Send>;
|
|
|
25
25
|
/// Type alias for boxed failure callback
|
|
26
26
|
type FailureCallback<E> = Box<dyn FnMut(&RetryError<E>) + Send>;
|
|
27
27
|
|
|
28
|
-
/// Type alias for boxed delay_from hook
|
|
28
|
+
/// Type alias for boxed `delay_from` hook
|
|
29
29
|
type DelayFromHook<E> = Box<dyn FnMut(&E, u8) -> DelayHint + Send>;
|
|
30
30
|
|
|
31
31
|
/// What a [`delay_from`](RetryBuilder::delay_from) hook wants the loop to do
|
|
@@ -103,7 +103,7 @@ pub struct RetryError<E> {
|
|
|
103
103
|
}
|
|
104
104
|
|
|
105
105
|
impl<E> RetryError<E> {
|
|
106
|
-
fn new(
|
|
106
|
+
const fn new(
|
|
107
107
|
kind: RetryErrorKind,
|
|
108
108
|
attempts: u8,
|
|
109
109
|
max_attempts: u8,
|
|
@@ -120,7 +120,7 @@ impl<E> RetryError<E> {
|
|
|
120
120
|
}
|
|
121
121
|
|
|
122
122
|
/// Retrieve the underlying cause when available.
|
|
123
|
-
pub fn cause(&self) -> Option<&E> {
|
|
123
|
+
pub const fn cause(&self) -> Option<&E> {
|
|
124
124
|
self.cause.as_ref()
|
|
125
125
|
}
|
|
126
126
|
|
|
@@ -130,22 +130,22 @@ impl<E> RetryError<E> {
|
|
|
130
130
|
}
|
|
131
131
|
|
|
132
132
|
/// Attempt number that produced the terminal outcome (1-indexed).
|
|
133
|
-
pub fn attempts(&self) -> u8 {
|
|
133
|
+
pub const fn attempts(&self) -> u8 {
|
|
134
134
|
self.attempts
|
|
135
135
|
}
|
|
136
136
|
|
|
137
137
|
/// Maximum attempts allowed by the policy.
|
|
138
|
-
pub fn max_attempts(&self) -> u8 {
|
|
138
|
+
pub const fn max_attempts(&self) -> u8 {
|
|
139
139
|
self.max_attempts
|
|
140
140
|
}
|
|
141
141
|
|
|
142
142
|
/// Total time spent in delays before reaching terminal state.
|
|
143
|
-
pub fn cumulative_delay_ms(&self) -> u64 {
|
|
143
|
+
pub const fn cumulative_delay_ms(&self) -> u64 {
|
|
144
144
|
self.cumulative_delay_ms
|
|
145
145
|
}
|
|
146
146
|
|
|
147
147
|
/// Error category.
|
|
148
|
-
pub fn kind(&self) -> RetryErrorKind {
|
|
148
|
+
pub const fn kind(&self) -> RetryErrorKind {
|
|
149
149
|
self.kind
|
|
150
150
|
}
|
|
151
151
|
}
|
|
@@ -178,7 +178,7 @@ where
|
|
|
178
178
|
write!(f, " (cumulative delay {}ms)", self.cumulative_delay_ms)?;
|
|
179
179
|
|
|
180
180
|
if let Some(cause) = self.cause.as_ref() {
|
|
181
|
-
write!(f, ": {}"
|
|
181
|
+
write!(f, ": {cause}")?;
|
|
182
182
|
}
|
|
183
183
|
|
|
184
184
|
Ok(())
|
|
@@ -197,7 +197,7 @@ pub struct RetryOutcome<T> {
|
|
|
197
197
|
}
|
|
198
198
|
|
|
199
199
|
impl<T> RetryOutcome<T> {
|
|
200
|
-
fn new(value: T, attempts: u8, cumulative_delay_ms: u64) -> Self {
|
|
200
|
+
const fn new(value: T, attempts: u8, cumulative_delay_ms: u64) -> Self {
|
|
201
201
|
Self {
|
|
202
202
|
value,
|
|
203
203
|
attempts,
|
|
@@ -206,17 +206,17 @@ impl<T> RetryOutcome<T> {
|
|
|
206
206
|
}
|
|
207
207
|
|
|
208
208
|
/// Attempt that succeeded (1-indexed).
|
|
209
|
-
pub fn attempts(&self) -> u8 {
|
|
209
|
+
pub const fn attempts(&self) -> u8 {
|
|
210
210
|
self.attempts
|
|
211
211
|
}
|
|
212
212
|
|
|
213
213
|
/// Total milliseconds spent sleeping between attempts.
|
|
214
|
-
pub fn cumulative_delay_ms(&self) -> u64 {
|
|
214
|
+
pub const fn cumulative_delay_ms(&self) -> u64 {
|
|
215
215
|
self.cumulative_delay_ms
|
|
216
216
|
}
|
|
217
217
|
|
|
218
218
|
/// Borrow the successful value.
|
|
219
|
-
pub fn value(&self) -> &T {
|
|
219
|
+
pub const fn value(&self) -> &T {
|
|
220
220
|
&self.value
|
|
221
221
|
}
|
|
222
222
|
|
|
@@ -306,11 +306,11 @@ pub trait RetryableExt<T, E>: Retryable<T, E> {
|
|
|
306
306
|
/// Create a retry builder with exponential backoff using default configuration
|
|
307
307
|
///
|
|
308
308
|
/// Default configuration:
|
|
309
|
-
/// - max_attempts
|
|
310
|
-
/// - base_delay_ms
|
|
311
|
-
/// - multiplier
|
|
312
|
-
/// - max_delay_ms
|
|
313
|
-
/// - jitter_factor
|
|
309
|
+
/// - `max_attempts`: 3
|
|
310
|
+
/// - `base_delay_ms`: 100
|
|
311
|
+
/// - `multiplier`: 2.0
|
|
312
|
+
/// - `max_delay_ms`: `10_000`
|
|
313
|
+
/// - `jitter_factor`: 1.0 (full jitter)
|
|
314
314
|
///
|
|
315
315
|
/// # Returns
|
|
316
316
|
///
|
|
@@ -343,8 +343,8 @@ pub trait RetryableExt<T, E>: Retryable<T, E> {
|
|
|
343
343
|
/// * `delay_ms` - Fixed delay in milliseconds between retry attempts
|
|
344
344
|
///
|
|
345
345
|
/// Default configuration (besides delay):
|
|
346
|
-
/// - max_attempts
|
|
347
|
-
/// - jitter_factor
|
|
346
|
+
/// - `max_attempts`: 3
|
|
347
|
+
/// - `jitter_factor`: 0.0 (no jitter)
|
|
348
348
|
///
|
|
349
349
|
/// # Returns
|
|
350
350
|
///
|
|
@@ -377,10 +377,10 @@ pub trait RetryableExt<T, E>: Retryable<T, E> {
|
|
|
377
377
|
/// Create a retry builder with Fibonacci backoff using default configuration
|
|
378
378
|
///
|
|
379
379
|
/// Default configuration:
|
|
380
|
-
/// - max_attempts
|
|
381
|
-
/// - base_delay_ms
|
|
382
|
-
/// - max_delay_ms
|
|
383
|
-
/// - jitter_factor
|
|
380
|
+
/// - `max_attempts`: 8
|
|
381
|
+
/// - `base_delay_ms`: 100
|
|
382
|
+
/// - `max_delay_ms`: `10_000`
|
|
383
|
+
/// - `jitter_factor`: 1.0 (full jitter)
|
|
384
384
|
///
|
|
385
385
|
/// Delays follow the Fibonacci sequence: 100ms, 100ms, 200ms, 300ms, 500ms...
|
|
386
386
|
///
|
|
@@ -424,6 +424,7 @@ impl<F, T, E> RetryableExt<T, E> for F where F: Retryable<T, E> {}
|
|
|
424
424
|
/// * `T` - The success return type
|
|
425
425
|
/// * `E` - The error type
|
|
426
426
|
/// * `W` - The when predicate type
|
|
427
|
+
#[must_use = "a RetryBuilder does nothing until one of its `call*` methods runs it"]
|
|
427
428
|
pub struct RetryBuilder<F, B, T, E, W> {
|
|
428
429
|
operation: F,
|
|
429
430
|
backoff: B,
|
|
@@ -644,10 +645,10 @@ where
|
|
|
644
645
|
));
|
|
645
646
|
}
|
|
646
647
|
|
|
647
|
-
let hint =
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
648
|
+
let hint = self
|
|
649
|
+
.delay_from
|
|
650
|
+
.as_mut()
|
|
651
|
+
.map_or(DelayHint::Backoff, |hook| hook(&error, attempt));
|
|
651
652
|
|
|
652
653
|
let delay_ms = match hint {
|
|
653
654
|
DelayHint::Backoff => {
|
|
@@ -721,6 +722,10 @@ where
|
|
|
721
722
|
///
|
|
722
723
|
/// The final result after all retry attempts (success or final error)
|
|
723
724
|
///
|
|
725
|
+
/// # Errors
|
|
726
|
+
///
|
|
727
|
+
/// See [`call_with_sleeper_and_rng`](Self::call_with_sleeper_and_rng).
|
|
728
|
+
///
|
|
724
729
|
/// # Example
|
|
725
730
|
///
|
|
726
731
|
/// ```rust
|
|
@@ -759,6 +764,10 @@ where
|
|
|
759
764
|
///
|
|
760
765
|
/// The final result after all retry attempts
|
|
761
766
|
///
|
|
767
|
+
/// # Errors
|
|
768
|
+
///
|
|
769
|
+
/// See [`call_with_sleeper_and_rng`](Self::call_with_sleeper_and_rng).
|
|
770
|
+
///
|
|
762
771
|
/// # Example
|
|
763
772
|
///
|
|
764
773
|
/// ```rust
|
|
@@ -797,6 +806,18 @@ where
|
|
|
797
806
|
///
|
|
798
807
|
/// * `sleeper` - Implementation of the [`Sleeper`] trait
|
|
799
808
|
/// * `rng` - Random number generator used for jitter
|
|
809
|
+
///
|
|
810
|
+
/// # Errors
|
|
811
|
+
///
|
|
812
|
+
/// Returns a [`RetryError`] carrying the operation's last error when the
|
|
813
|
+
/// retry gives up: [`RetryErrorKind::Exhausted`] once the strategy is out
|
|
814
|
+
/// of attempts, [`RetryErrorKind::PredicateRejected`] when the `when`
|
|
815
|
+
/// predicate declines the error, or [`RetryErrorKind::HintHalted`] when a
|
|
816
|
+
/// `delay_from` hook halts.
|
|
817
|
+
#[expect(
|
|
818
|
+
clippy::needless_pass_by_value,
|
|
819
|
+
reason = "published signature; sleepers are ZST/fn-pointer values passed inline"
|
|
820
|
+
)]
|
|
800
821
|
pub fn call_with_sleeper_and_rng<S: Sleeper, R: rand::Rng>(
|
|
801
822
|
mut self,
|
|
802
823
|
sleeper: S,
|
|
@@ -904,6 +925,10 @@ where
|
|
|
904
925
|
/// the operation or mid-sleep. The in-flight attempt is dropped with it, so
|
|
905
926
|
/// the operation must be cancel-safe if that matters to the caller;
|
|
906
927
|
/// `on_failure` does not fire, because nothing failed.
|
|
928
|
+
///
|
|
929
|
+
/// # Errors
|
|
930
|
+
///
|
|
931
|
+
/// See [`call_async_with_rng`](Self::call_async_with_rng).
|
|
907
932
|
#[cfg(feature = "std")]
|
|
908
933
|
pub async fn call_async<S: crate::sleep::AsyncSleeper>(
|
|
909
934
|
self,
|
|
@@ -919,6 +944,11 @@ where
|
|
|
919
944
|
/// [`call_with_sleeper_and_rng`](Self::call_with_sleeper_and_rng): callers
|
|
920
945
|
/// provide their own [`rand::Rng`] for jitter instead of relying on the
|
|
921
946
|
/// `std`-only `make_rng`.
|
|
947
|
+
///
|
|
948
|
+
/// # Errors
|
|
949
|
+
///
|
|
950
|
+
/// Same as [`call_with_sleeper_and_rng`](Self::call_with_sleeper_and_rng):
|
|
951
|
+
/// both drivers share one retry policy.
|
|
922
952
|
pub async fn call_async_with_rng<S: crate::sleep::AsyncSleeper, R: rand::Rng>(
|
|
923
953
|
mut self,
|
|
924
954
|
sleeper: S,
|
|
@@ -942,5 +972,8 @@ where
|
|
|
942
972
|
}
|
|
943
973
|
}
|
|
944
974
|
|
|
975
|
+
// These tests drive `call`/`call_with_sleeper` and share `std` sync types;
|
|
976
|
+
// the `no_std` driver is covered from the crate-root tests.
|
|
945
977
|
#[cfg(test)]
|
|
978
|
+
#[cfg(feature = "std")]
|
|
946
979
|
mod tests;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
//! Fixtures shared by the crate's unit tests.
|
|
2
|
+
|
|
3
|
+
use std::sync::{Mutex, MutexGuard, PoisonError};
|
|
4
|
+
|
|
5
|
+
/// Serializes tests that use the process-wide policy registry, which
|
|
6
|
+
/// `cargo test` would otherwise clear and repopulate from several threads at
|
|
7
|
+
/// once.
|
|
8
|
+
static GLOBAL_REGISTRY: Mutex<()> = Mutex::new(());
|
|
9
|
+
|
|
10
|
+
/// Hold for the whole test body. Poisoning is ignored so one failing test
|
|
11
|
+
/// doesn't cascade into every other registry test.
|
|
12
|
+
pub fn lock_global_registry() -> MutexGuard<'static, ()> {
|
|
13
|
+
GLOBAL_REGISTRY
|
|
14
|
+
.lock()
|
|
15
|
+
.unwrap_or_else(PoisonError::into_inner)
|
|
16
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
[package]
|
|
2
2
|
name = "chrono_machines_native"
|
|
3
|
-
version = "0.
|
|
4
|
-
edition =
|
|
3
|
+
version = "0.9.1"
|
|
4
|
+
edition.workspace = true
|
|
5
|
+
rust-version.workspace = true
|
|
5
6
|
authors = ["Abdelkader Boudih <terminale@gmail.com>"]
|
|
6
7
|
license = "MIT"
|
|
7
8
|
description = "Ruby FFI binding for chrono_machines"
|
|
@@ -14,10 +15,13 @@ crate-type = ["cdylib"]
|
|
|
14
15
|
|
|
15
16
|
[dependencies]
|
|
16
17
|
chrono-machines = { workspace = true, features = ["std"] }
|
|
17
|
-
magnus = "0.
|
|
18
|
+
magnus = "0.9"
|
|
18
19
|
rand = { workspace = true }
|
|
19
|
-
rb-sys = { version = "0.9.
|
|
20
|
+
rb-sys = { version = "0.9.124", default-features = false, features = ["stable-api-compiled-fallback"] }
|
|
20
21
|
|
|
21
22
|
[features]
|
|
22
23
|
default = ["std"]
|
|
23
24
|
std = ["chrono-machines/std"]
|
|
25
|
+
|
|
26
|
+
[lints]
|
|
27
|
+
workspace = true
|
|
@@ -1,11 +1,8 @@
|
|
|
1
|
-
//! Ruby FFI binding for chrono_machines
|
|
1
|
+
//! Ruby FFI binding for `chrono_machines`
|
|
2
2
|
//!
|
|
3
|
-
//! This crate provides a Magnus-based Ruby binding for the chrono_machines library.
|
|
3
|
+
//! This crate provides a Magnus-based Ruby binding for the `chrono_machines` library.
|
|
4
4
|
//! It exposes a simple helper function for calculating delays with exponential backoff.
|
|
5
5
|
|
|
6
|
-
#![warn(rust_2024_compatibility)]
|
|
7
|
-
#![warn(clippy::all)]
|
|
8
|
-
|
|
9
6
|
use chrono_machines::fibonacci;
|
|
10
7
|
use magnus::{Error, Ruby, function};
|
|
11
8
|
use rand::RngExt;
|
|
@@ -36,8 +33,7 @@ fn calculate_delay_exponential(
|
|
|
36
33
|
jitter_factor: f64,
|
|
37
34
|
) -> f64 {
|
|
38
35
|
let jitter_factor = normalize_jitter(jitter_factor);
|
|
39
|
-
let
|
|
40
|
-
let exponent = attempt_u8.saturating_sub(1) as i32;
|
|
36
|
+
let exponent = i32::from(attempt_u8(attempt).saturating_sub(1));
|
|
41
37
|
|
|
42
38
|
let base_exponential = base_delay * multiplier.powi(exponent);
|
|
43
39
|
let capped = base_exponential.min(max_delay);
|
|
@@ -76,16 +72,28 @@ fn calculate_delay_fibonacci(
|
|
|
76
72
|
jitter_factor: f64,
|
|
77
73
|
) -> f64 {
|
|
78
74
|
let jitter_factor = normalize_jitter(jitter_factor);
|
|
79
|
-
let attempt_u8 = attempt.clamp(1, 255) as u8;
|
|
80
75
|
|
|
81
|
-
|
|
82
|
-
|
|
76
|
+
// Fibonacci numbers pass 2^53 at n = 79; from there the cast rounds to the
|
|
77
|
+
// nearest f64, an error far below anything a delay in seconds can express.
|
|
78
|
+
#[expect(
|
|
79
|
+
clippy::cast_precision_loss,
|
|
80
|
+
reason = "u64 -> f64 has no lossless conversion; rounding only starts above 2^53"
|
|
81
|
+
)]
|
|
82
|
+
let fib = fibonacci(attempt_u8(attempt)) as f64;
|
|
83
|
+
let base = (base_delay * fib).min(max_delay);
|
|
83
84
|
|
|
84
85
|
apply_jitter(base, jitter_factor)
|
|
85
86
|
}
|
|
86
87
|
|
|
88
|
+
/// Clamp a Ruby attempt number into the `1..=255` range the core works in.
|
|
89
|
+
fn attempt_u8(attempt: i64) -> u8 {
|
|
90
|
+
// The clamp makes the conversion infallible; `unwrap_or` only spares the
|
|
91
|
+
// FFI boundary a panic path.
|
|
92
|
+
u8::try_from(attempt.clamp(1, i64::from(u8::MAX))).unwrap_or(u8::MAX)
|
|
93
|
+
}
|
|
94
|
+
|
|
87
95
|
/// Normalize jitter factor to [0.0, 1.0] range
|
|
88
|
-
fn normalize_jitter(jitter_factor: f64) -> f64 {
|
|
96
|
+
const fn normalize_jitter(jitter_factor: f64) -> f64 {
|
|
89
97
|
if jitter_factor.is_nan() {
|
|
90
98
|
1.0
|
|
91
99
|
} else {
|
|
@@ -95,17 +103,30 @@ fn normalize_jitter(jitter_factor: f64) -> f64 {
|
|
|
95
103
|
|
|
96
104
|
/// Apply jitter to a base delay value
|
|
97
105
|
fn apply_jitter(base: f64, jitter_factor: f64) -> f64 {
|
|
98
|
-
RNG.
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
106
|
+
let random_scalar: f64 = RNG.with_borrow_mut(|rng| rng.random_range(0.0..=1.0));
|
|
107
|
+
// Kept as separate mul + add rather than `mul_add`: results stay
|
|
108
|
+
// bit-identical to earlier releases, and on targets without hardware FMA
|
|
109
|
+
// (the x86_64 baseline) `mul_add` is a libm call, not a speed-up.
|
|
110
|
+
#[expect(
|
|
111
|
+
clippy::suboptimal_flops,
|
|
112
|
+
reason = "bit-identical jitter; mul_add is a libm call without hardware FMA"
|
|
113
|
+
)]
|
|
114
|
+
let jitter_blend = 1.0 - jitter_factor + random_scalar * jitter_factor;
|
|
115
|
+
base * jitter_blend
|
|
104
116
|
}
|
|
105
117
|
|
|
106
118
|
/// Initialize the Ruby extension
|
|
107
119
|
#[magnus::init]
|
|
108
120
|
fn init(ruby: &Ruby) -> Result<(), Error> {
|
|
121
|
+
// Must precede every method definition: Ruby marks methods Ractor-safe as
|
|
122
|
+
// they are defined. The RNG is thread-local, so Ractors never share it.
|
|
123
|
+
// SAFETY: called on the loading thread during extension initialisation,
|
|
124
|
+
// which is the only context `rb_ext_ractor_safe` is specified for.
|
|
125
|
+
#[allow(unsafe_code)]
|
|
126
|
+
unsafe {
|
|
127
|
+
rb_sys::rb_ext_ractor_safe(true);
|
|
128
|
+
}
|
|
129
|
+
|
|
109
130
|
// Create ChronoMachinesNative module
|
|
110
131
|
let module = ruby.define_module("ChronoMachinesNative")?;
|
|
111
132
|
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: chrono_machines
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.9.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Abdelkader Boudih
|
|
@@ -47,6 +47,7 @@ files:
|
|
|
47
47
|
- ext/chrono_machines_native/core/src/policy.rs
|
|
48
48
|
- ext/chrono_machines_native/core/src/retry.rs
|
|
49
49
|
- ext/chrono_machines_native/core/src/sleep.rs
|
|
50
|
+
- ext/chrono_machines_native/core/src/test_support.rs
|
|
50
51
|
- ext/chrono_machines_native/extconf.rb
|
|
51
52
|
- ext/chrono_machines_native/ffi/Cargo.toml
|
|
52
53
|
- ext/chrono_machines_native/ffi/extconf.rb
|
|
@@ -79,14 +80,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
79
80
|
requirements:
|
|
80
81
|
- - ">="
|
|
81
82
|
- !ruby/object:Gem::Version
|
|
82
|
-
version:
|
|
83
|
+
version: 4.0.0
|
|
83
84
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
84
85
|
requirements:
|
|
85
86
|
- - ">="
|
|
86
87
|
- !ruby/object:Gem::Version
|
|
87
88
|
version: '0'
|
|
88
89
|
requirements: []
|
|
89
|
-
rubygems_version:
|
|
90
|
+
rubygems_version: 4.0.20
|
|
90
91
|
specification_version: 4
|
|
91
92
|
summary: A robust Ruby gem for implementing retry mechanisms with exponential backoff
|
|
92
93
|
and jitter.
|