chrono_machines 0.9.2-aarch64-linux → 0.10.0-aarch64-linux

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: '09a86cb08e19697cbac9456c15de806f0d5ba1461345675319a014340c5e580f'
4
- data.tar.gz: '0558916e71edf61d3643433f2ba247804b1ef4bf86a88ba890f410d50a126743'
3
+ metadata.gz: aeef5fdf2ecf22b876dedb43acf7873ac34ba2ca05249490ab5b190048885ce6
4
+ data.tar.gz: 1256e269112ab4703288f2a5d44bd8f7183230a3b2f8e40278a1e756e9f55fcb
5
5
  SHA512:
6
- metadata.gz: c0bab09a36137f09c991551a38354b164c908da05a05a58fa51492455f866e2fb4862912e654be5c28ed82ea53622d99c3e76cdf361fd24fe3c5497b2c2ddfc6
7
- data.tar.gz: e416d74f419f6c1f89ef2814af04e27cf9dc4f57f5c2f3f2be04458833cf6aaee9cf4efaedcb56f2323391c8cba726a6b71cd68601630efea4e471af9fe18feb
6
+ metadata.gz: 5ad2fbc6e409420019e0589ed9d6b582a777c6d2e0a25bad4951d274af9c94df0811ae89b21558be6f3592ab69e8dfd0f7a0b59fe3115cbd14c85686eb131353
7
+ data.tar.gz: 9a64d22915c64d7a4a708abf18649a486338bbbcf5a58443ea4f11cc6e714e35758be3b2a8c6137d6c0432c8b7c0736d3fd02416e1fc861e2c0233ffd3246ee0
data/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.10.0](https://github.com/seuros/chrono_machines/compare/chrono_machines/v0.9.2...chrono_machines/v0.10.0) (2026-10-11)
4
+
5
+
6
+ ### Features
7
+
8
+ * expose retry delays without executing or sleeping ([#30](https://github.com/seuros/chrono_machines/issues/30)) ([2f6ce19](https://github.com/seuros/chrono_machines/commit/2f6ce1999c7d2056d3adc8e57e0025c22087ca01))
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * reject a jitter random source without #rand ([50734ee](https://github.com/seuros/chrono_machines/commit/50734eee35608d8a8324b38cf38f9ab1cc101c65))
14
+
3
15
  ## [0.9.2](https://github.com/seuros/chrono_machines/compare/chrono_machines/v0.9.1...chrono_machines/v0.9.2) (2026-10-10)
4
16
 
5
17
 
data/README.md CHANGED
@@ -221,6 +221,39 @@ The hook's return value controls the retry:
221
221
 
222
222
  Anything else raises `ArgumentError` - a broken hook fails loudly instead of silently mangling your retry cadence.
223
223
 
224
+ ### Scheduling retries without sleeping
225
+
226
+ `Executor#next_delay(attempt:, exception: nil)` returns seconds to wait, or `nil` to stop. `attempt: 1` means the initial call has failed; `max_attempts` includes that initial call. It uses the same backoff, jitter, exception rules, and `delay_from` hints as `call`, without executing work, sleeping, or running lifecycle callbacks. The caller owns the attempt count and scheduling. Without an exception, `delay_from` and exception filtering are skipped.
227
+
228
+ For Active Job's `retry_on`, keep the attempt limit in sync with the policy:
229
+
230
+ ```ruby
231
+ class FetchJob < ApplicationJob
232
+ RETRY_POLICY = ChronoMachines::Executor.new(max_attempts: 5, base_delay: 1)
233
+
234
+ retry_on IOError, attempts: 5,
235
+ wait: ->(executions) { RETRY_POLICY.next_delay(attempt: executions) }
236
+ end
237
+ ```
238
+
239
+ `retry_on` calls `wait` only while `executions < attempts`. If the two limits drift apart, `next_delay` returns `nil` and Active Job retries immediately, so keep `attempts:` equal to `max_attempts`.
240
+
241
+ To use exception hints with durable jobs, pass the attempt count to the next job:
242
+
243
+ ```ruby
244
+ def perform(record_id, attempt: 1)
245
+ fetch_record(record_id)
246
+ rescue IOError => error
247
+ delay = RETRY_POLICY.next_delay(attempt: attempt, exception: error)
248
+ raise unless delay
249
+
250
+ self.class.set(wait_until: Time.current + delay)
251
+ .perform_later(record_id, attempt: attempt + 1)
252
+ end
253
+ ```
254
+
255
+ A hint beyond `max_delay` returns `nil`; it is never shortened to the cap. Use `random: Random.new(seed)` for deterministic jitter in tests, with either the Ruby or native backend.
256
+
224
257
  ## The Science of Temporal Jitter
225
258
 
226
259
  ChronoMachines implements **full jitter** exponential backoff:
@@ -239,16 +272,19 @@ ChronoMachines implements **full jitter** exponential backoff:
239
272
 
240
273
  This prevents the "thundering herd" problem where multiple clients retry simultaneously, overwhelming recovering services.
241
274
 
275
+ Jitter is drawn from the `random:` option (Ruby's `Random` by default) on both backends; the native extension computes only the un-jittered base delay.
276
+
242
277
  ## Configuration Reference
243
278
 
244
279
  ### Policy Options
245
280
 
246
281
  | Option | Default | Description |
247
282
  |--------|---------|-------------|
248
- | `max_attempts` | `3` | Maximum number of retry attempts |
283
+ | `max_attempts` | `3` | Maximum number of attempts, including the initial call |
249
284
  | `base_delay` | `0.1` | Initial delay in seconds |
250
285
  | `multiplier` | `2` | Exponential backoff multiplier |
251
286
  | `max_delay` | `10` | Maximum delay cap in seconds |
287
+ | `random` | `Random` | Random source for jitter; accepts `Random.new(seed)` |
252
288
  | `retryable_exceptions` | `[StandardError]` | Array of exception classes to retry |
253
289
  | `on_success` | `nil` | Success callback: `(result:, attempts:)` |
254
290
  | `on_retry` | `nil` | Retry callback: `(exception:, attempt:, next_delay:)` |
@@ -15,6 +15,8 @@ module ChronoMachines
15
15
  @multiplier = policy_options[:multiplier] || 2
16
16
  @max_delay = policy_options[:max_delay]
17
17
  @jitter_factor = policy_options[:jitter_factor]
18
+ @random = policy_options[:random] || Random
19
+ raise ArgumentError, "random must respond to #rand; got #{@random.inspect}" unless @random.respond_to?(:rand)
18
20
  @retryable_exceptions = policy_options[:retryable_exceptions]
19
21
  @delay_from = policy_options[:delay_from]
20
22
  @on_failure = policy_options[:on_failure]
@@ -22,6 +24,14 @@ module ChronoMachines
22
24
  @on_success = policy_options[:on_success]
23
25
  end
24
26
 
27
+ def next_delay(attempt:, exception: nil)
28
+ raise ArgumentError, 'attempt must be a positive Integer' unless attempt.is_a?(Integer) && attempt.positive?
29
+ return if exception && !retryable_exception?(exception)
30
+ return if attempt >= @max_attempts
31
+
32
+ resolve_delay(exception, attempt)
33
+ end
34
+
25
35
  def call
26
36
  attempts = 0
27
37
 
@@ -34,22 +44,14 @@ module ChronoMachines
34
44
 
35
45
  result
36
46
  rescue StandardError => e
37
- # Check if exception is retryable
38
- unless @retryable_exceptions.any? { |ex| e.is_a?(ex) }
39
- # Non-retryable exception - call failure callback and re-raise
47
+ delay = next_delay(attempt: attempts, exception: e)
48
+ unless delay
40
49
  handle_final_failure(e, attempts)
41
- raise e
42
- end
50
+ raise MaxRetriesExceededError.new(e, attempts) if retryable_exception?(e) && attempts >= @max_attempts
43
51
 
44
- # Check if we've exhausted all attempts
45
- if attempts >= @max_attempts
46
- handle_final_failure(e, attempts)
47
- raise MaxRetriesExceededError.new(e, attempts)
52
+ raise e
48
53
  end
49
54
 
50
- # Calculate delay
51
- delay = resolve_delay(e, attempts)
52
-
53
55
  # Call retry callback if defined
54
56
  @on_retry&.call(exception: e, attempt: attempts, next_delay: delay)
55
57
 
@@ -61,24 +63,25 @@ module ChronoMachines
61
63
 
62
64
  private
63
65
 
66
+ def retryable_exception?(exception)
67
+ @retryable_exceptions.any? { |type| exception.is_a?(type) }
68
+ end
69
+
64
70
  # delay_from(exception:, attempt:) overrides the backoff: nil -> backoff,
65
- # Numeric -> verbatim (beyond max_delay: halt), :halt/false -> re-raise.
71
+ # Numeric -> verbatim (beyond max_delay: halt), :halt/false -> stop.
66
72
  def resolve_delay(exception, attempts)
67
- return calculate_delay(attempts) unless @delay_from
73
+ return calculate_delay(attempts) unless exception && @delay_from
68
74
 
69
75
  hint = @delay_from.call(exception: exception, attempt: attempts)
70
76
  case hint
71
77
  when nil
72
78
  calculate_delay(attempts)
73
79
  when Numeric
74
- if @max_delay && hint > @max_delay
75
- handle_final_failure(exception, attempts)
76
- raise exception
77
- end
80
+ return if @max_delay && hint > @max_delay
81
+
78
82
  hint
79
83
  when :halt, false
80
- handle_final_failure(exception, attempts)
81
- raise exception
84
+ nil
82
85
  else
83
86
  raise ArgumentError, "delay_from must return nil, a Numeric, :halt or false; got #{hint.inspect}"
84
87
  end
@@ -87,22 +90,19 @@ module ChronoMachines
87
90
  # Pure Ruby implementation of delay calculation (exponential backoff)
88
91
  def ruby_calculate_delay_exponential(attempts)
89
92
  base_exponential_delay = [@base_delay * (@multiplier**(attempts - 1)), @max_delay].min
90
- jitter_factor = normalized_jitter_factor
91
- base_exponential_delay * (1 - jitter_factor + (rand * jitter_factor))
93
+ apply_jitter(base_exponential_delay)
92
94
  end
93
95
 
94
96
  # Pure Ruby implementation of constant backoff
95
97
  def ruby_calculate_delay_constant(_attempts)
96
- jitter_factor = normalized_jitter_factor
97
- @base_delay * (1 - jitter_factor + (rand * jitter_factor))
98
+ apply_jitter(@base_delay)
98
99
  end
99
100
 
100
101
  # Pure Ruby implementation of Fibonacci backoff
101
102
  def ruby_calculate_delay_fibonacci(attempts)
102
103
  fib = fibonacci(attempts)
103
104
  base_delay = [@base_delay * fib, @max_delay].min
104
- jitter_factor = normalized_jitter_factor
105
- base_delay * (1 - jitter_factor + (rand * jitter_factor))
105
+ apply_jitter(base_delay)
106
106
  end
107
107
 
108
108
  # Calculate Fibonacci number (1-indexed)
@@ -134,6 +134,11 @@ module ChronoMachines
134
134
  # By default, use Ruby implementation (may be overridden by native extension)
135
135
  alias_method :calculate_delay, :ruby_calculate_delay
136
136
 
137
+ def apply_jitter(delay)
138
+ jitter_factor = normalized_jitter_factor
139
+ delay * (1 - jitter_factor + (@random.rand * jitter_factor))
140
+ end
141
+
137
142
  def robust_sleep(delay)
138
143
  # Handle potential interruptions to sleep
139
144
  # In Ruby 3.2+, Kernel.sleep is fiber-aware
@@ -22,32 +22,34 @@ begin
22
22
 
23
23
  # Override calculate_delay to dispatch to native implementations
24
24
  def calculate_delay(attempts)
25
- case @backoff_strategy
26
- when :exponential
27
- ChronoMachinesNative.exponential_delay(
28
- attempts,
29
- @base_delay,
30
- @multiplier,
31
- @max_delay,
32
- normalized_jitter_factor
33
- )
34
- when :constant
35
- ChronoMachinesNative.constant_delay(
36
- attempts,
37
- @base_delay,
38
- normalized_jitter_factor
39
- )
40
- when :fibonacci
41
- ChronoMachinesNative.fibonacci_delay(
42
- attempts,
43
- @base_delay,
44
- @max_delay,
45
- normalized_jitter_factor
46
- )
47
- else
48
- # Unknown strategy, fall back to Ruby
49
- super
50
- end
25
+ delay = case @backoff_strategy
26
+ when :exponential
27
+ ChronoMachinesNative.exponential_delay(
28
+ attempts,
29
+ @base_delay,
30
+ @multiplier,
31
+ @max_delay,
32
+ 0.0
33
+ )
34
+ when :constant
35
+ ChronoMachinesNative.constant_delay(
36
+ attempts,
37
+ @base_delay,
38
+ 0.0
39
+ )
40
+ when :fibonacci
41
+ ChronoMachinesNative.fibonacci_delay(
42
+ attempts,
43
+ @base_delay,
44
+ @max_delay,
45
+ 0.0
46
+ )
47
+ else
48
+ # Unknown strategy, fall back to Ruby
49
+ return super
50
+ end
51
+
52
+ apply_jitter(delay)
51
53
  end
52
54
  end
53
55
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ChronoMachines
4
- VERSION = '0.9.2'
4
+ VERSION = '0.10.0'
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: chrono_machines
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.2
4
+ version: 0.10.0
5
5
  platform: aarch64-linux
6
6
  authors:
7
7
  - Abdelkader Boudih
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-10 00:00:00.000000000 Z
11
+ date: 2026-10-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: zeitwerk