cmdx 2.0.0 → 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 +62 -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 +40 -56
  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 +15 -11
  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 +10 -6
  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 +28 -11
  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 +22 -40
  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 +52 -11
  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 +96 -33
  57. data/mkdocs.yml +2 -0
  58. metadata +1 -1
data/lib/cmdx/input.rb CHANGED
@@ -128,6 +128,11 @@ module CMDx
128
128
  # validation error to `task.errors`. Returns `nil` when coercion or any
129
129
  # validator fails (the failure message is recorded on `task.errors`).
130
130
  #
131
+ # @note "Required" here means "the key is present in the source"; an
132
+ # explicit `nil` under an existing key satisfies the required check
133
+ # and is then routed through `:default`. Combine with `:presence` /
134
+ # `:validate` to reject explicit `nil` values.
135
+ #
131
136
  # @param task [Task]
132
137
  # @return [Object, nil] the resolved value (`nil` on failure)
133
138
  def resolve(task)
@@ -177,10 +182,6 @@ module CMDx
177
182
 
178
183
  private
179
184
 
180
- # @param value [Object] candidate after source resolution
181
- # @param key_provided [Boolean] whether the source reported an explicit key/value pair
182
- # @param task [Task]
183
- # @return [Object, nil]
184
185
  def run_pipeline(value, key_provided, task)
185
186
  if required?(task) && !key_provided
186
187
  task.errors.add(accessor_name, I18nProxy.t("cmdx.attributes.required"))
@@ -202,15 +203,13 @@ module CMDx
202
203
  value
203
204
  end
204
205
 
205
- # @param task [Task]
206
- # @return [Array(Object, Boolean)] `[value, key_provided]`
207
206
  def resolve_with_key(task)
208
207
  case source
209
208
  when :context
210
209
  [task.context[name], task.context.key?(name)]
211
210
  when Symbol
212
211
  obj = task.send(source)
213
- return [nil, false] if obj.nil?
212
+ return [nil, false] unless obj
214
213
 
215
214
  fetch_by_name(obj)
216
215
  when Proc
@@ -218,20 +217,19 @@ module CMDx
218
217
  else
219
218
  return [source.call(task), true] if source.respond_to?(:call)
220
219
 
221
- raise ArgumentError, "source must be a Symbol, Proc, or respond to #call"
220
+ raise ArgumentError, <<~MSG.chomp
221
+ input source must be a Symbol, Proc, or respond to #call (got #{source.class}).
222
+ See https://drexed.github.io/cmdx/inputs/definitions/#sources
223
+ MSG
222
224
  end
223
225
  end
224
226
 
225
- # @param parent_value [#[], #key?, Object]
226
- # @return [Array(Object, Boolean)] `[value, key_provided]`
227
227
  def resolve_from_parent_with_key(parent_value)
228
- return [nil, false] unless parent_value.respond_to?(:[])
228
+ return [nil, false] unless parent_value.respond_to?(:[]) || parent_value.respond_to?(:fetch)
229
229
 
230
230
  fetch_by_name(parent_value)
231
231
  end
232
232
 
233
- # @param obj [Object] source object (`Hash`, duck-typed reader, etc.)
234
- # @return [Array(Object, Boolean)] `[value, key_provided]`
235
233
  def fetch_by_name(obj)
236
234
  if obj.respond_to?(name, true)
237
235
  [obj.send(name), true]
@@ -243,9 +241,15 @@ module CMDx
243
241
  else
244
242
  [nil, false]
245
243
  end
244
+ elsif obj.respond_to?(:fetch)
245
+ # Prefer #fetch with a sentinel so an explicit `nil` value is
246
+ # distinguishable from a missing key (Hash, Array, etc.).
247
+ value = obj.fetch(name) { obj.fetch(name.to_s) { EMPTY_SENTINEL } }
248
+ value.equal?(EMPTY_SENTINEL) ? [nil, false] : [value, true]
246
249
  elsif obj.respond_to?(:[])
247
- # Without #key? we cannot distinguish "key absent" from "value is nil",
248
- # so an explicit nil is treated as not provided (triggers default/required).
250
+ # Without #key? or #fetch we cannot distinguish "key absent" from
251
+ # "value is nil", so an explicit nil is treated as not provided
252
+ # (triggers default/required).
249
253
  value = obj[name] || obj[name.to_s]
250
254
  [value, !value.nil?]
251
255
  else
@@ -253,8 +257,6 @@ module CMDx
253
257
  end
254
258
  end
255
259
 
256
- # @param task [Task]
257
- # @return [Object, nil]
258
260
  def apply_default(task)
259
261
  return if default.nil?
260
262
 
@@ -270,9 +272,6 @@ module CMDx
270
272
  end
271
273
  end
272
274
 
273
- # @param value [Object]
274
- # @param task [Task]
275
- # @return [Object]
276
275
  def apply_transform(value, task)
277
276
  case transform
278
277
  when Symbol
@@ -286,7 +285,10 @@ module CMDx
286
285
  else
287
286
  return transform.call(value, task) if transform.respond_to?(:call)
288
287
 
289
- raise ArgumentError, "transform must be a Symbol, Proc, or respond to #call"
288
+ raise ArgumentError, <<~MSG.chomp
289
+ input transform must be a Symbol, Proc, or respond to #call (got #{transform.class}).
290
+ See https://drexed.github.io/cmdx/inputs/transformations/#declarations
291
+ MSG
290
292
  end
291
293
  end
292
294
 
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,20 +58,21 @@ 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
 
71
74
  private
72
75
 
73
- # @param group [Workflow::ExecutionGroup]
74
- # @return [Result, nil] failed result to halt on, or nil when the group succeeds
75
76
  def run_sequential(group)
76
77
  continue = group.options[:continue_on_failure]
77
78
  failures = group.tasks.each_with_object([]) do |task_class, bucket|
@@ -87,8 +88,6 @@ module CMDx
87
88
  aggregate(failures, continue:)
88
89
  end
89
90
 
90
- # @param group [Workflow::ExecutionGroup]
91
- # @return [Result, nil] failed result to halt on, or nil when the group succeeds
92
91
  def run_parallel(group)
93
92
  tasks = group.tasks
94
93
  chain = Chain.current
@@ -142,21 +141,39 @@ module CMDx
142
141
  end
143
142
 
144
143
  def rollback_executed!
144
+ first_rollback_error = nil
145
+
145
146
  @executed.reverse_each do |instance, result|
146
147
  next unless result.success?
147
148
  next unless instance.respond_to?(:rollback)
148
149
 
149
- instance.rollback
150
-
151
150
  old_opts = result.instance_variable_get(:@options)
152
151
  new_opts = old_opts.merge(rolled_back: true).freeze
153
152
  result.instance_variable_set(:@options, new_opts)
153
+
154
+ emit_telemetry(instance, :task_rolled_back)
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
154
164
  end
165
+
166
+ raise first_rollback_error if first_rollback_error
167
+ end
168
+
169
+ def emit_telemetry(instance, name, payload = EMPTY_HASH)
170
+ telemetry = instance.class.telemetry
171
+ return unless telemetry.subscribed?(name)
172
+
173
+ event = Telemetry::Event.build(instance, name, root: false, payload:)
174
+ telemetry.emit(name, event)
155
175
  end
156
176
 
157
- # @param failures [Array<Result>]
158
- # @param continue [Boolean] when true, merges failures into the workflow's errors
159
- # @return [Result, nil] first failure (echoed upstream), or nil when `failures` is empty
160
177
  def aggregate(failures, continue:)
161
178
  return if failures.empty?
162
179
  return failures.first unless continue
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