cmdx 2.0.1 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +53 -8
  3. data/lib/cmdx/callbacks.rb +31 -11
  4. data/lib/cmdx/chain.rb +29 -10
  5. data/lib/cmdx/coercions/big_decimal.rb +1 -1
  6. data/lib/cmdx/coercions/boolean.rb +3 -9
  7. data/lib/cmdx/coercions/coerce.rb +4 -1
  8. data/lib/cmdx/coercions/date_time.rb +1 -1
  9. data/lib/cmdx/coercions/integer.rb +11 -2
  10. data/lib/cmdx/coercions/symbol.rb +23 -4
  11. data/lib/cmdx/coercions.rb +25 -10
  12. data/lib/cmdx/configuration.rb +31 -16
  13. data/lib/cmdx/context.rb +36 -52
  14. data/lib/cmdx/deprecation.rb +4 -7
  15. data/lib/cmdx/deprecators/error.rb +4 -1
  16. data/lib/cmdx/deprecators.rb +17 -8
  17. data/lib/cmdx/errors.rb +11 -10
  18. data/lib/cmdx/executors/fiber.rb +16 -4
  19. data/lib/cmdx/executors/thread.rb +18 -4
  20. data/lib/cmdx/executors.rb +22 -7
  21. data/lib/cmdx/fault.rb +15 -3
  22. data/lib/cmdx/i18n_proxy.rb +9 -5
  23. data/lib/cmdx/input.rb +23 -21
  24. data/lib/cmdx/inputs.rb +14 -26
  25. data/lib/cmdx/log_formatters/json.rb +8 -1
  26. data/lib/cmdx/log_formatters/logstash.rb +7 -1
  27. data/lib/cmdx/mergers.rb +22 -7
  28. data/lib/cmdx/middlewares.rb +40 -24
  29. data/lib/cmdx/output.rb +5 -2
  30. data/lib/cmdx/pipeline.rb +18 -3
  31. data/lib/cmdx/railtie.rb +1 -0
  32. data/lib/cmdx/result.rb +22 -6
  33. data/lib/cmdx/retriers/decorrelated_jitter.rb +10 -5
  34. data/lib/cmdx/retriers/exponential.rb +10 -2
  35. data/lib/cmdx/retriers/fibonacci.rb +29 -12
  36. data/lib/cmdx/retriers.rb +17 -8
  37. data/lib/cmdx/retry.rb +20 -13
  38. data/lib/cmdx/runtime.rb +18 -17
  39. data/lib/cmdx/settings.rb +9 -9
  40. data/lib/cmdx/signal.rb +1 -1
  41. data/lib/cmdx/task.rb +90 -45
  42. data/lib/cmdx/telemetry.rb +37 -10
  43. data/lib/cmdx/util.rb +50 -4
  44. data/lib/cmdx/validators/absence.rb +1 -1
  45. data/lib/cmdx/validators/exclusion.rb +15 -15
  46. data/lib/cmdx/validators/format.rb +12 -4
  47. data/lib/cmdx/validators/inclusion.rb +15 -15
  48. data/lib/cmdx/validators/length.rb +5 -49
  49. data/lib/cmdx/validators/numeric.rb +5 -49
  50. data/lib/cmdx/validators/presence.rb +1 -1
  51. data/lib/cmdx/validators/validate.rb +7 -1
  52. data/lib/cmdx/validators.rb +21 -9
  53. data/lib/cmdx/version.rb +1 -1
  54. data/lib/cmdx/workflow.rb +28 -14
  55. data/lib/cmdx.rb +24 -0
  56. data/lib/generators/cmdx/templates/install.rb +80 -39
  57. data/mkdocs.yml +1 -0
  58. metadata +1 -1
data/lib/cmdx/inputs.rb CHANGED
@@ -51,14 +51,17 @@ module CMDx
51
51
  self
52
52
  end
53
53
 
54
- # Removes inputs and their accessor readers from `klass`.
54
+ # Removes inputs and their accessor readers from `klass`. Unknown names
55
+ # are silently ignored (matching {Outputs#deregister} semantics).
55
56
  #
56
57
  # @param klass [Class]
57
58
  # @param names [Array<Symbol>]
58
59
  # @return [Inputs] self for chaining
59
60
  def deregister(klass, *names)
60
61
  names.each do |name|
61
- input = registry.delete(name.to_sym)
62
+ input = registry.delete(name)
63
+ next if input.nil?
64
+
62
65
  klass.send(:undefine_input_reader, input)
63
66
  end
64
67
 
@@ -90,10 +93,6 @@ module CMDx
90
93
 
91
94
  private
92
95
 
93
- # @param input [Input] parent input whose children should be resolved
94
- # @param parent_value [Object] resolved parent value child inputs read from
95
- # @param task [Task]
96
- # @return [void]
97
96
  def resolve_children(input, parent_value, task)
98
97
  return if input.children.empty? || parent_value.nil?
99
98
 
@@ -149,6 +148,9 @@ module CMDx
149
148
  alias input inputs
150
149
 
151
150
  # Declares optional child inputs (equivalent to `inputs ..., required: false`).
151
+ # An explicit `required:` in `options` is ignored — use {#inputs} when
152
+ # you need to set the flag dynamically.
153
+ #
152
154
  # @param names [Array<Symbol>]
153
155
  # @param options [Hash{Symbol => Object}] forwarded to {Input#initialize}
154
156
  # @option options [String] :description (also accepts `:desc`)
@@ -165,10 +167,13 @@ module CMDx
165
167
  # @yield nested child input DSL
166
168
  # @return [Array<Input>]
167
169
  def optional(*names, **options, &)
168
- build(*names, required: false, **options, &)
170
+ build(*names, **options, required: false, &)
169
171
  end
170
172
 
171
173
  # Declares required child inputs (equivalent to `inputs ..., required: true`).
174
+ # An explicit `required:` in `options` is ignored — use {#inputs} when
175
+ # you need to set the flag dynamically.
176
+ #
172
177
  # @param names [Array<Symbol>]
173
178
  # @param options [Hash{Symbol => Object}] forwarded to {Input#initialize}
174
179
  # @option options [String] :description (also accepts `:desc`)
@@ -185,31 +190,14 @@ module CMDx
185
190
  # @yield nested child input DSL
186
191
  # @return [Array<Input>]
187
192
  def required(*names, **options, &)
188
- build(*names, required: true, **options, &)
193
+ build(*names, **options, required: true, &)
189
194
  end
190
195
 
191
196
  private
192
197
 
193
- # @param names [Array<Symbol>]
194
- # @param block [#call, nil]
195
- # @param options [Hash{Symbol => Object}] forwarded to {Input#initialize}
196
- # @option options [String] :description (also accepts `:desc`)
197
- # @option options [Symbol] :as overrides the accessor name
198
- # @option options [Boolean, String] :prefix prefix for the accessor name
199
- # @option options [Boolean, String] :suffix suffix for the accessor name
200
- # @option options [Symbol, Proc, #call] :source (`:context`) where to fetch from
201
- # @option options [Object, Symbol, Proc, #call] :default
202
- # @option options [Symbol, Proc, #call] :transform mutator applied after coercion
203
- # @option options [Symbol, Proc, #call] :if
204
- # @option options [Symbol, Proc, #call] :unless
205
- # @option options [Boolean] :required
206
- # @option options [Object] :coerce forwarded with declaration (see {Coercions#extract})
207
- # @option options [Object] :validate forwarded with declaration (see {Validators#extract})
208
- # @return [Array<Input>]
209
- # @yield nested child input DSL
210
198
  def build(*names, **options, &block)
211
199
  nested = block ? self.class.build(&block) : EMPTY_ARRAY
212
- names.map { |name| children << Input.new(name, children: nested, **options) }
200
+ names.each { |name| children << Input.new(name, children: nested, **options) }
213
201
  end
214
202
 
215
203
  end
@@ -4,7 +4,10 @@ module CMDx
4
4
  module LogFormatters
5
5
  # `Logger` formatter that emits one JSON object per line with `severity`,
6
6
  # ISO8601 `timestamp`, `progname`, `pid`, and `message` (rendered via
7
- # `#to_h` when available — Result instances serialize themselves).
7
+ # `#to_h` when available — Result instances serialize themselves). Falls
8
+ # back to `message.inspect` if the original `JSON.dump` raises (e.g. for
9
+ # non-JSON-encodable or cyclic structures) so logging never crashes the
10
+ # caller.
8
11
  class JSON
9
12
 
10
13
  # @param severity [String] Logger severity name
@@ -21,6 +24,10 @@ module CMDx
21
24
  message: message.respond_to?(:to_h) ? message.to_h : message
22
25
  }
23
26
 
27
+ ::JSON.dump(hash) << "\n"
28
+ rescue StandardError => e
29
+ hash[:message] = message.inspect
30
+ hash[:logerr] = Util.to_error_s(e)
24
31
  ::JSON.dump(hash) << "\n"
25
32
  end
26
33
 
@@ -3,7 +3,9 @@
3
3
  module CMDx
4
4
  module LogFormatters
5
5
  # `Logger` formatter that produces one JSON line per entry in the shape
6
- # expected by Logstash (`@version` + `@timestamp`).
6
+ # expected by Logstash (`@version` + `@timestamp`). Falls back to
7
+ # `message.inspect` if `JSON.dump` raises (e.g. cyclic / non-encodable
8
+ # payload) so logging never crashes the caller.
7
9
  class Logstash
8
10
 
9
11
  # @param severity [String] Logger severity name
@@ -21,6 +23,10 @@ module CMDx
21
23
  "@timestamp" => time.utc.iso8601(6)
22
24
  }
23
25
 
26
+ ::JSON.dump(hash) << "\n"
27
+ rescue StandardError => e
28
+ hash[:message] = message.inspect
29
+ hash[:logerr] = Util.to_error_s(e)
24
30
  ::JSON.dump(hash) << "\n"
25
31
  end
26
32
 
data/lib/cmdx/mergers.rb CHANGED
@@ -36,9 +36,12 @@ module CMDx
36
36
  merger = callable || block
37
37
 
38
38
  if callable && block
39
- raise ArgumentError, "provide either a callable or a block, not both"
39
+ raise ArgumentError, "merger: provide either a callable or a block, not both"
40
40
  elsif !merger.respond_to?(:call)
41
- raise ArgumentError, "merger must respond to #call"
41
+ raise ArgumentError, <<~MSG.chomp
42
+ merger must respond to #call (got #{merger.class}).
43
+ See https://drexed.github.io/cmdx/workflows/#merge-strategies
44
+ MSG
42
45
  end
43
46
 
44
47
  registry[name.to_sym] = merger
@@ -48,16 +51,25 @@ module CMDx
48
51
  # @param name [Symbol]
49
52
  # @return [Mergers] self for chaining
50
53
  def deregister(name)
51
- registry.delete(name.to_sym)
54
+ registry.delete(name)
52
55
  self
53
56
  end
54
57
 
58
+ # @param name [Symbol]
59
+ # @return [Boolean] whether a merger is registered under `name`
60
+ def key?(name)
61
+ registry.key?(name)
62
+ end
63
+
55
64
  # @param name [Symbol]
56
65
  # @return [#call] the registered merger
57
- # @raise [ArgumentError] when `name` isn't registered
66
+ # @raise [UnknownEntryError] when `name` isn't registered
58
67
  def lookup(name)
59
68
  registry[name] || begin
60
- raise ArgumentError, "unknown merger: #{name.inspect}"
69
+ raise UnknownEntryError, <<~MSG.chomp
70
+ unknown merger #{name.inspect}; registered: #{registry.keys.inspect}.
71
+ See https://drexed.github.io/cmdx/workflows/#merge-strategies
72
+ MSG
61
73
  end
62
74
  end
63
75
 
@@ -67,7 +79,7 @@ module CMDx
67
79
  #
68
80
  # @param spec [Symbol, #call, nil]
69
81
  # @return [#call]
70
- # @raise [ArgumentError] when `spec` is an unknown symbol or not callable
82
+ # @raise [UnknownEntryError] when `spec` is an unknown symbol or not callable
71
83
  def resolve(spec)
72
84
  case spec
73
85
  when NilClass
@@ -77,7 +89,10 @@ module CMDx
77
89
  else
78
90
  return spec if spec.respond_to?(:call)
79
91
 
80
- raise ArgumentError, "unknown merger: #{spec.inspect}"
92
+ raise UnknownEntryError, <<~MSG.chomp
93
+ unknown merger #{spec.inspect}; expected a Symbol from #{registry.keys.inspect} or a callable.
94
+ See https://drexed.github.io/cmdx/workflows/#merge-strategies
95
+ MSG
81
96
  end
82
97
  end
83
98
 
@@ -37,11 +37,17 @@ module CMDx
37
37
  at = options.delete(:at)
38
38
 
39
39
  if callable && block
40
- raise ArgumentError, "provide either a callable or a block, not both"
40
+ raise ArgumentError, "middleware: provide either a callable or a block, not both"
41
41
  elsif !middleware.respond_to?(:call)
42
- raise ArgumentError, "middleware must respond to #call"
42
+ raise ArgumentError, <<~MSG.chomp
43
+ middleware must respond to #call (got #{middleware.class}).
44
+ See https://drexed.github.io/cmdx/middlewares/#signature
45
+ MSG
43
46
  elsif !at.nil? && !at.is_a?(Integer)
44
- raise ArgumentError, "at must be an Integer"
47
+ raise ArgumentError, <<~MSG.chomp
48
+ middleware :at must be an Integer (got #{at.class}).
49
+ See https://drexed.github.io/cmdx/middlewares/#ordering
50
+ MSG
45
51
  end
46
52
 
47
53
  entry = [middleware, options.freeze]
@@ -49,8 +55,7 @@ module CMDx
49
55
  if at.nil?
50
56
  registry << entry
51
57
  else
52
- at = [at.clamp(-registry.size - 1, registry.size), registry.size].min
53
- registry.insert(at, entry)
58
+ registry.insert(at.clamp(-registry.size - 1, registry.size), entry)
54
59
  end
55
60
 
56
61
  self
@@ -65,11 +70,14 @@ module CMDx
65
70
  # or when `:at` isn't an Integer
66
71
  def deregister(middleware = nil, at: nil)
67
72
  if at.nil? && middleware.nil?
68
- raise ArgumentError, "provide either a middleware or an at: index"
73
+ raise ArgumentError, "middleware: provide either a middleware or an at: index"
69
74
  elsif !at.nil? && !middleware.nil?
70
- raise ArgumentError, "provide either a middleware or an at: index, not both"
75
+ raise ArgumentError, "middleware: provide either a middleware or an at: index, not both"
71
76
  elsif !at.nil? && !at.is_a?(Integer)
72
- raise ArgumentError, "at must be an Integer"
77
+ raise ArgumentError, <<~MSG.chomp
78
+ middleware :at must be an Integer (got #{at.class}).
79
+ See https://drexed.github.io/cmdx/middlewares/#ordering
80
+ MSG
73
81
  end
74
82
 
75
83
  if at.nil?
@@ -94,6 +102,10 @@ module CMDx
94
102
  # Walks the middleware chain around `task`'s lifecycle. The final link
95
103
  # yields to `block`, which is expected to run the actual lifecycle.
96
104
  #
105
+ # Built as an iterative reverse-reduce (matching {Callbacks#around}),
106
+ # avoiding the per-link recursive lambda invocation of the previous
107
+ # implementation while preserving identical semantics.
108
+ #
97
109
  # @param task [Task]
98
110
  # @yield the innermost link — the task's lifecycle body
99
111
  # @return [void]
@@ -101,26 +113,30 @@ module CMDx
101
113
  # which would otherwise silently skip the task
102
114
  def process(task)
103
115
  processed = false
104
- count = registry.size
105
-
106
- chain = lambda do |i|
107
- if i == count
108
- processed = true
109
- yield
110
- else
111
- mw, opts = registry[i]
112
-
113
- if Util.satisfied?(opts[:if], opts[:unless], task)
114
- mw.call(task) { chain.call(i + 1) }
115
- else
116
- chain.call(i + 1)
117
- end
116
+ last_invoked = nil
117
+
118
+ innermost = lambda do
119
+ processed = true
120
+ yield
121
+ end
122
+
123
+ chain = registry.reverse_each.reduce(innermost) do |succ, (mw, opts)|
124
+ lambda do
125
+ next succ.call unless Util.satisfied?(opts[:if], opts[:unless], task)
126
+
127
+ last_invoked = mw
128
+ mw.call(task) { succ.call }
118
129
  end
119
130
  end
120
- chain.call(0)
131
+
132
+ chain.call
121
133
 
122
134
  processed || begin
123
- raise MiddlewareError, "middleware did not yield the next_link"
135
+ offender = last_invoked.is_a?(Class) ? last_invoked : last_invoked.class
136
+ raise MiddlewareError, <<~MSG.chomp
137
+ middleware #{offender} did not yield to next_link.
138
+ See https://drexed.github.io/cmdx/middlewares/#safety
139
+ MSG
124
140
  end
125
141
  end
126
142
 
data/lib/cmdx/output.rb CHANGED
@@ -75,6 +75,11 @@ module CMDx
75
75
  # supplied a value (every declared output is implicitly required).
76
76
  # 4. Writes the resolved value back to `task.context[name]`.
77
77
  #
78
+ # @note "Missing" here means "the key was never written"; an explicit `nil`
79
+ # under an existing key is treated as "present" and the value remains
80
+ # `nil` (unless `:default` overrides it). To reject explicit `nil`, write
81
+ # a non-nil value or set a non-nil `:default`.
82
+ #
78
83
  # @param task [Task] the running task whose context is inspected and mutated
79
84
  # @return [void]
80
85
  def verify(task)
@@ -94,8 +99,6 @@ module CMDx
94
99
 
95
100
  private
96
101
 
97
- # @param task [Task]
98
- # @return [Object, nil]
99
102
  def apply_default(task)
100
103
  return if default.nil?
101
104
 
data/lib/cmdx/pipeline.rb CHANGED
@@ -58,13 +58,16 @@ module CMDx
58
58
  when :parallel
59
59
  run_parallel(group)
60
60
  else
61
- raise ArgumentError, "invalid strategy: #{strategy.inspect}"
61
+ raise ArgumentError, <<~MSG.chomp
62
+ invalid pipeline strategy #{strategy.inspect}; expected one of [:sequential, :parallel].
63
+ See https://drexed.github.io/cmdx/workflows/#group-options
64
+ MSG
62
65
  end
63
66
 
64
67
  next unless halt
65
68
 
66
69
  rollback_executed!
67
- @workflow.send(:throw!, halt)
70
+ @workflow.throw!(halt)
68
71
  end
69
72
  end
70
73
 
@@ -138,6 +141,8 @@ module CMDx
138
141
  end
139
142
 
140
143
  def rollback_executed!
144
+ first_rollback_error = nil
145
+
141
146
  @executed.reverse_each do |instance, result|
142
147
  next unless result.success?
143
148
  next unless instance.respond_to?(:rollback)
@@ -147,8 +152,18 @@ module CMDx
147
152
  result.instance_variable_set(:@options, new_opts)
148
153
 
149
154
  emit_telemetry(instance, :task_rolled_back)
150
- instance.rollback
155
+
156
+ begin
157
+ instance.rollback
158
+ rescue StandardError => e
159
+ first_rollback_error ||= e
160
+ instance.logger.error do
161
+ "rollback for #{instance.class} (#{instance.tid}) raised: #{Util.to_error_s(e)}"
162
+ end
163
+ end
151
164
  end
165
+
166
+ raise first_rollback_error if first_rollback_error
152
167
  end
153
168
 
154
169
  def emit_telemetry(instance, name, payload = EMPTY_HASH)
data/lib/cmdx/railtie.rb CHANGED
@@ -10,6 +10,7 @@ module CMDx
10
10
 
11
11
  initializer("cmdx.configure_rails") do |app|
12
12
  available_locales = app.config.i18n.available_locales.join(",")
13
+ available_locales = "*" if available_locales.empty?
13
14
  locale_path = File.expand_path("../locales/{#{available_locales}}.yml", __dir__)
14
15
  ::I18n.load_path += Dir[locale_path]
15
16
 
data/lib/cmdx/result.rb CHANGED
@@ -52,7 +52,9 @@ module CMDx
52
52
  task.type
53
53
  end
54
54
 
55
- # @return [String, nil] correlation id or the global configuration's correlation id
55
+ # @return [String, nil] resolved correlation id from this result's chain
56
+ # (produced by the configured `correlation_id` callable when the root
57
+ # chain was acquired)
56
58
  def xid
57
59
  chain.xid
58
60
  end
@@ -142,12 +144,14 @@ module CMDx
142
144
  # .on(:success) { |r| deliver(r.context) }
143
145
  # .on(:failed) { |r| alert(r.reason) }
144
146
  def on(*keys)
145
- raise ArgumentError, "block required" unless block_given?
147
+ raise ArgumentError, "Result#on requires a block" unless block_given?
146
148
 
147
149
  yield(self) if keys.any? do |k|
148
150
  unless EVENTS.include?(k.to_sym)
149
- raise ArgumentError,
150
- "unknown event #{k.inspect}, must be one of #{EVENTS.join(', ')}"
151
+ raise ArgumentError, <<~MSG.chomp
152
+ unknown Result#on event #{k.inspect}, must be one of #{EVENTS.to_a.inspect}.
153
+ See https://drexed.github.io/cmdx/outcomes/result/#predicate-dispatch
154
+ MSG
151
155
  end
152
156
 
153
157
  public_send(:"#{k}?")
@@ -180,6 +184,20 @@ module CMDx
180
184
  @signal.cause
181
185
  end
182
186
 
187
+ # Convenience accessor that returns the underlying exception when the
188
+ # failure was produced by a rescued exception, otherwise the human
189
+ # `reason`. `nil` for non-failed results. Useful for telemetry adapters
190
+ # (Sentry, Bugsnag, ...) that branch on whether an exception is
191
+ # available without forcing every subscriber to repeat the
192
+ # `cause || reason` dance.
193
+ #
194
+ # @return [Exception, String, nil]
195
+ def error
196
+ return unless failed?
197
+
198
+ cause || reason
199
+ end
200
+
183
201
  # The originating failed result at the bottom of the propagation chain.
184
202
  # Walks `origin` recursively. `self` when this result is the originator;
185
203
  # `nil` when not failed.
@@ -346,8 +364,6 @@ module CMDx
346
364
 
347
365
  private
348
366
 
349
- # @param key [Symbol] reader name such as `:caused_failure` or `:threw_failure`
350
- # @return [Hash{Symbol => Object}, nil] compact `{task:, tid:}` map for graph hints
351
367
  def hash_for_failure(key)
352
368
  r = public_send(key)
353
369
  return if r.nil?
@@ -3,9 +3,12 @@
3
3
  module CMDx
4
4
  class Retriers
5
5
  # AWS-recommended decorrelated jitter. Produces a uniform delay in
6
- # `[delay, prev_delay * 3]`, threading state across attempts via
7
- # `prev_delay`. When no previous delay exists the upper bound collapses
8
- # to `3 * delay`, matching the AWS reference implementation.
6
+ # `[delay, max(prev_delay * 3, delay)]`, threading state across attempts
7
+ # via `prev_delay`. When no previous delay exists the upper bound
8
+ # collapses to `3 * delay`, matching the AWS reference implementation.
9
+ # The lower bound is pinned at `delay` even when `prev_delay * 3 < delay`
10
+ # (e.g. after `:max_delay` clamping or mixed strategies), guaranteeing
11
+ # the returned value is never below the configured base.
9
12
  #
10
13
  # @see https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/
11
14
  # @api private
@@ -17,10 +20,12 @@ module CMDx
17
20
  # @param delay [Float] base delay in seconds (also the lower bound)
18
21
  # @param prev_delay [Float, nil] previous computed delay; falls back to
19
22
  # `delay` so the first call samples in `[delay, 3*delay]`
20
- # @return [Float] computed delay
23
+ # @return [Float] computed delay, never less than `delay`
21
24
  def call(_attempt, delay, prev_delay = nil)
22
25
  base = prev_delay || delay
23
- delay + (rand * ((base * 3) - delay))
26
+ high = base * 3
27
+ high = delay if high < delay
28
+ delay + (rand * (high - delay))
24
29
  end
25
30
 
26
31
  end
@@ -3,19 +3,27 @@
3
3
  module CMDx
4
4
  class Retriers
5
5
  # Exponential backoff. Doubles the base delay every attempt:
6
- # `delay * (2 ** attempt)`.
6
+ # `delay * (2 ** attempt)`. The shift is saturated at {MAX_SHIFT} to keep
7
+ # the math (and resulting sleep) bounded; pair with `:max_delay` to set
8
+ # the true upper bound.
7
9
  #
8
10
  # @api private
9
11
  module Exponential
10
12
 
11
13
  extend self
12
14
 
15
+ # Hard cap on the doubling exponent. `2 ** 30 ≈ 1.07e9` so paired with
16
+ # any sensible base delay the unclamped result is large enough to be
17
+ # noticed but never blows up into Bignum allocations or `Infinity`.
18
+ MAX_SHIFT = 30
19
+
13
20
  # @param attempt [Integer] zero-based retry attempt
14
21
  # @param delay [Float] base delay in seconds
15
22
  # @param _prev_delay [Float, nil] ignored
16
23
  # @return [Float] computed delay
17
24
  def call(attempt, delay, _prev_delay = nil)
18
- delay * (2**attempt)
25
+ shift = [attempt, MAX_SHIFT].min
26
+ delay * (1 << shift)
19
27
  end
20
28
 
21
29
  end
@@ -11,27 +11,44 @@ module CMDx
11
11
 
12
12
  extend self
13
13
 
14
+ # Hard cap on the index to keep multipliers/integer allocations bounded.
15
+ # `fib(78) < 2**63`, well past any realistic retry attempt. Pair with
16
+ # `:max_delay` for the actual sleep ceiling.
17
+ MAX_INDEX = 78
18
+
19
+ # Cache of computed Fibonacci numbers. Shared across calls so consecutive
20
+ # retries reuse prior work instead of recomputing from zero. Reads are
21
+ # lock-free; growth is performed on a local dup and swapped atomically
22
+ # under the mutex.
23
+ @cache = [0, 1].freeze
24
+ @mutex = Mutex.new
25
+
14
26
  # @param attempt [Integer] zero-based retry attempt
15
27
  # @param delay [Float] base delay in seconds
16
28
  # @param _prev_delay [Float, nil] ignored
17
29
  # @return [Float] computed delay
18
30
  def call(attempt, delay, _prev_delay = nil)
19
- delay * sequence(attempt + 1)
31
+ index = attempt + 1
32
+ index = MAX_INDEX if index > MAX_INDEX
33
+ delay * fib(index)
20
34
  end
21
35
 
22
36
  private
23
37
 
24
- # Iterative Fibonacci. `sequence(1) == 1`, `sequence(2) == 1`,
25
- # `sequence(3) == 2`, ...
26
- #
27
- # @param n [Integer] one-based index into the Fibonacci sequence
28
- # @return [Integer]
29
- # @api private
30
- def sequence(n)
31
- a = 0
32
- b = 1
33
- n.times { a, b = b, a + b }
34
- a
38
+ def fib(n)
39
+ cache = @cache
40
+ return cache[n] if n < cache.size
41
+
42
+ @mutex.synchronize do
43
+ cache = @cache
44
+ if cache.size <= n
45
+ grown = cache.dup
46
+ grown << (grown[-1] + grown[-2]) while grown.size <= n
47
+ @cache = grown.freeze
48
+ end
49
+ end
50
+
51
+ @cache[n]
35
52
  end
36
53
 
37
54
  end
data/lib/cmdx/retriers.rb CHANGED
@@ -41,9 +41,12 @@ module CMDx
41
41
  retrier = callable || block
42
42
 
43
43
  if callable && block
44
- raise ArgumentError, "provide either a callable or a block, not both"
44
+ raise ArgumentError, "retrier: provide either a callable or a block, not both"
45
45
  elsif !retrier.respond_to?(:call)
46
- raise ArgumentError, "retrier must respond to #call"
46
+ raise ArgumentError, <<~MSG.chomp
47
+ retrier must respond to #call (got #{retrier.class}).
48
+ See https://drexed.github.io/cmdx/retries/#custom-strategies-via-the-retriers-registry
49
+ MSG
47
50
  end
48
51
 
49
52
  registry[name.to_sym] = retrier
@@ -53,22 +56,25 @@ module CMDx
53
56
  # @param name [Symbol]
54
57
  # @return [Retriers] self for chaining
55
58
  def deregister(name)
56
- registry.delete(name.to_sym)
59
+ registry.delete(name)
57
60
  self
58
61
  end
59
62
 
60
63
  # @param name [Symbol]
61
64
  # @return [Boolean] whether a retrier is registered under `name`
62
65
  def key?(name)
63
- registry.key?(name.to_sym)
66
+ registry.key?(name)
64
67
  end
65
68
 
66
69
  # @param name [Symbol]
67
70
  # @return [#call] the registered retrier
68
- # @raise [ArgumentError] when `name` isn't registered
71
+ # @raise [UnknownEntryError] when `name` isn't registered
69
72
  def lookup(name)
70
73
  registry[name] || begin
71
- raise ArgumentError, "unknown retrier: #{name.inspect}"
74
+ raise UnknownEntryError, <<~MSG.chomp
75
+ unknown retrier #{name.inspect}; registered: #{registry.keys.inspect}.
76
+ See https://drexed.github.io/cmdx/retries/#built-in-strategies
77
+ MSG
72
78
  end
73
79
  end
74
80
 
@@ -78,7 +84,7 @@ module CMDx
78
84
  #
79
85
  # @param spec [Symbol, #call, nil]
80
86
  # @return [#call, nil]
81
- # @raise [ArgumentError] when `spec` is an unknown symbol or not callable
87
+ # @raise [UnknownEntryError] when `spec` is an unknown symbol or not callable
82
88
  def resolve(spec)
83
89
  case spec
84
90
  when NilClass
@@ -88,7 +94,10 @@ module CMDx
88
94
  else
89
95
  return spec if spec.respond_to?(:call)
90
96
 
91
- raise ArgumentError, "unknown retrier: #{spec.inspect}"
97
+ raise UnknownEntryError, <<~MSG.chomp
98
+ unknown retrier #{spec.inspect}; expected a Symbol from #{registry.keys.inspect} or a callable.
99
+ See https://drexed.github.io/cmdx/retries/#built-in-strategies
100
+ MSG
92
101
  end
93
102
  end
94
103