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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c6cbe05e9e40deb1bf5b846c57800d1f4439caf6cd753883562a7f911a453640
4
- data.tar.gz: ae0a5c30e033a6bdd974ff9ea8bbac5220f2aa316a9b6724897cefcdd23971cf
3
+ metadata.gz: 5b11004f5b8e42aefeb73fc0a754d18af5503163e75ba23b2e55f15f767c9b39
4
+ data.tar.gz: af89d525918c2164ef919dbafc840fd8f34c91cf3d97c9ff1067e4f8a07d0d4e
5
5
  SHA512:
6
- metadata.gz: 3d99472379c0ff2a7adf1a4035698e6d7f18f5e763096f389bb8ccb12d273a55e97bd4ffc62cca04b52436ed64f8cd294c76a8aacd6a0ca11982df7b9ee7a07c
7
- data.tar.gz: 7e6d0d99d5c074b978f9c3595245c1aa37ffaaf214e74d6ccbb75d16436c8bcd76383ca6d92abb265caf8cd359099e289d7d20cf2b3d0caf0d2e2a21db009b5b
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.0"
61
+ version = "0.10.2"
62
62
  source = "registry+https://github.com/rust-lang/crates.io-index"
63
- checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601"
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.6.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.5.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.8.2"
236
+ version = "0.9.2"
237
237
  source = "registry+https://github.com/rust-lang/crates.io-index"
238
- checksum = "3b36a5b126bbe97eb0d02d07acfeb327036c6319fd816139a49824a83b7f9012"
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.8.0"
248
+ version = "0.9.1"
249
249
  source = "registry+https://github.com/rust-lang/crates.io-index"
250
- checksum = "47607461fd8e1513cb4f2076c197d8092d921a1ea75bd08af97398f593751892"
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.1"
321
+ version = "0.10.3"
322
322
  source = "registry+https://github.com/rust-lang/crates.io-index"
323
- checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207"
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 = "2"
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.0"
4
- edition = "2024"
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
- pub(crate) fn powi_f64(base: f64, exp: i32) -> f64 {
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
- fn apply_jitter<R: Rng>(base: f64, jitter_factor: f64, rng: &mut R) -> u64 {
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: base_delay * multiplier^(attempt-1)
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
- pub fn new() -> Self {
160
- Self::default()
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
- pub fn base_delay_ms(mut self, ms: u64) -> Self {
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
- pub fn multiplier(mut self, multiplier: f64) -> Self {
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
- pub fn max_delay_ms(mut self, ms: u64) -> Self {
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
- pub fn max_attempts(mut self, attempts: u8) -> Self {
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
- pub fn jitter_factor(mut self, factor: f64) -> Self {
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 exponent = attempt.saturating_sub(1) as i32;
213
- let base_exponential = (self.base_delay_ms as f64) * powi_f64(self.multiplier, exponent);
214
- let capped = base_exponential.min(self.max_delay_ms as f64);
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
- pub fn new() -> Self {
259
- Self::default()
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
- pub fn delay_ms(mut self, ms: u64) -> Self {
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
- pub fn max_attempts(mut self, attempts: u8) -> Self {
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
- pub fn jitter_factor(mut self, factor: f64) -> Self {
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(self.delay_ms as f64, self.jitter_factor, rng))
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
- pub fn new() -> Self {
340
- Self::default()
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
- pub fn base_delay_ms(mut self, ms: u64) -> Self {
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
- pub fn max_delay_ms(mut self, ms: u64) -> Self {
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
- pub fn max_attempts(mut self, attempts: u8) -> Self {
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
- pub fn jitter_factor(mut self, factor: f64) -> Self {
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 = ((self.base_delay_ms as f64) * (fib as f64)).min(self.max_delay_ms as f64);
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
- pub fn max_attempts(&self) -> u8 {
487
+ #[must_use]
488
+ pub const fn max_attempts(&self) -> u8 {
421
489
  match self {
422
- BackoffPolicy::Exponential(policy) => policy.max_attempts,
423
- BackoffPolicy::Constant(policy) => policy.max_attempts,
424
- BackoffPolicy::Fibonacci(policy) => policy.max_attempts,
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
- BackoffPolicy::Exponential(policy) => policy.delay(attempt, rng),
433
- BackoffPolicy::Constant(policy) => policy.delay(attempt, rng),
434
- BackoffPolicy::Fibonacci(policy) => policy.delay(attempt, rng),
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
- BackoffPolicy::Exponential(policy) => policy.should_retry(attempt),
441
- BackoffPolicy::Constant(policy) => policy.should_retry(attempt),
442
- BackoffPolicy::Fibonacci(policy) => policy.should_retry(attempt),
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
- BackoffPolicy::Exponential(policy) => policy.max_attempts(),
449
- BackoffPolicy::Constant(policy) => policy.max_attempts(),
450
- BackoffPolicy::Fibonacci(policy) => policy.max_attempts(),
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
- BackoffPolicy::Exponential(policy) => policy.max_delay_ms(),
457
- BackoffPolicy::Constant(policy) => policy.max_delay_ms(),
458
- BackoffPolicy::Fibonacci(policy) => policy.max_delay_ms(),
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
- BackoffPolicy::Exponential(value)
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
- BackoffPolicy::Constant(value)
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
- BackoffPolicy::Fibonacci(value)
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
- DslError::Execution(value)
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
- DslError::PolicyMissing(name) => write!(f, "retry policy '{}' is not registered", name),
36
- DslError::Execution(err) => write!(f, "{err}"),
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
- //! - **no_std compatible**: Works in embedded environments
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
- pub fn new() -> Self {
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
- // Normalize jitter factor to the inclusive range [0.0, 1.0]
195
- let mut jitter_factor = jitter_factor;
196
- if jitter_factor.is_nan() {
197
- jitter_factor = 1.0;
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 = jitter_factor.clamp(0.0, 1.0);
200
- }
201
-
202
- // Calculate base exponential backoff
203
- let exponent = attempt.saturating_sub(1) as i32;
204
- let base_exponential =
205
- (self.base_delay_ms as f64) * crate::backoff::powi_f64(self.multiplier, exponent);
206
-
207
- // Cap at max_delay
208
- let capped = base_exponential.min(self.max_delay_ms as f64);
209
-
210
- // Apply jitter: blend between deterministic and random delay
211
- // jitter_factor of 1.0 = full jitter (0 to base), 0.0 = no jitter (exactly base)
212
- // Formula: base * (1 - jitter_factor + rand * jitter_factor)
213
- // Example with jitter_factor=0.1: base * (0.9 + rand*0.1) = 90% to 100% of base
214
- let random_scalar: f64 = rng.random_range(0.0..=1.0);
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
- pub fn should_retry(&self, current_attempt: u8) -> bool {
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
- pub fn new() -> Self {
32
- Self::default()
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.to_vec()
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::{OnceLock, RwLock};
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 global_registry() -> &'static RwLock<PolicyRegistry> {
97
- static GLOBAL_POLICIES: OnceLock<RwLock<PolicyRegistry>> = OnceLock::new();
98
- GLOBAL_POLICIES.get_or_init(|| RwLock::new(PolicyRegistry::new()))
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
- let mut guard = global_registry()
108
- .write()
109
- .expect("chronomachines global policy registry poisoned");
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
- let guard = global_registry()
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
- let mut guard = global_registry()
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
- let guard = global_registry()
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
- let mut guard = global_registry()
144
- .write()
145
- .expect("chronomachines global policy registry poisoned");
146
- guard.clear();
160
+ write_global().clear();
147
161
  }
148
162
 
149
- #[cfg(all(test, any(feature = "std", feature = "alloc")))]
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, ": {}", cause)?;
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: 3
310
- /// - base_delay_ms: 100
311
- /// - multiplier: 2.0
312
- /// - max_delay_ms: 10_000
313
- /// - jitter_factor: 1.0 (full jitter)
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: 3
347
- /// - jitter_factor: 0.0 (no jitter)
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: 8
381
- /// - base_delay_ms: 100
382
- /// - max_delay_ms: 10_000
383
- /// - jitter_factor: 1.0 (full jitter)
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 = match self.delay_from {
648
- Some(ref mut hook) => hook(&error, attempt),
649
- None => DelayHint::Backoff,
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;
@@ -1,4 +1,4 @@
1
- //! Sleep abstraction for no_std compatibility
1
+ //! Sleep abstraction for `no_std` compatibility
2
2
  //!
3
3
  //! Two traits live here, and which one you want is decided by your runtime:
4
4
  //!
@@ -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.8.0"
4
- edition = "2024"
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.8.2"
18
+ magnus = "0.9"
18
19
  rand = { workspace = true }
19
- rb-sys = { version = "0.9.117", default-features = false, features = ["stable-api-compiled-fallback"] }
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 attempt_u8 = attempt.clamp(1, 255) as u8;
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
- let fib = fibonacci(attempt_u8);
82
- let base = (base_delay * fib as f64).min(max_delay);
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.with(|rng| {
99
- let mut rng = rng.borrow_mut();
100
- let random_scalar: f64 = rng.random_range(0.0..=1.0);
101
- let jitter_blend = 1.0 - jitter_factor + random_scalar * jitter_factor;
102
- base * jitter_blend
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
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ChronoMachines
4
- VERSION = '0.8.0'
4
+ VERSION = '0.9.2'
5
5
  end
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.8.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: 3.3.0
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: 3.6.9
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.