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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +62 -8
- data/lib/cmdx/callbacks.rb +31 -11
- data/lib/cmdx/chain.rb +29 -10
- data/lib/cmdx/coercions/big_decimal.rb +1 -1
- data/lib/cmdx/coercions/boolean.rb +3 -9
- data/lib/cmdx/coercions/coerce.rb +4 -1
- data/lib/cmdx/coercions/date_time.rb +1 -1
- data/lib/cmdx/coercions/integer.rb +11 -2
- data/lib/cmdx/coercions/symbol.rb +23 -4
- data/lib/cmdx/coercions.rb +25 -10
- data/lib/cmdx/configuration.rb +31 -16
- data/lib/cmdx/context.rb +40 -56
- data/lib/cmdx/deprecation.rb +4 -7
- data/lib/cmdx/deprecators/error.rb +4 -1
- data/lib/cmdx/deprecators.rb +17 -8
- data/lib/cmdx/errors.rb +15 -11
- data/lib/cmdx/executors/fiber.rb +16 -4
- data/lib/cmdx/executors/thread.rb +18 -4
- data/lib/cmdx/executors.rb +22 -7
- data/lib/cmdx/fault.rb +15 -3
- data/lib/cmdx/i18n_proxy.rb +10 -6
- data/lib/cmdx/input.rb +23 -21
- data/lib/cmdx/inputs.rb +14 -26
- data/lib/cmdx/log_formatters/json.rb +8 -1
- data/lib/cmdx/log_formatters/logstash.rb +7 -1
- data/lib/cmdx/mergers.rb +22 -7
- data/lib/cmdx/middlewares.rb +40 -24
- data/lib/cmdx/output.rb +5 -2
- data/lib/cmdx/pipeline.rb +28 -11
- data/lib/cmdx/railtie.rb +1 -0
- data/lib/cmdx/result.rb +22 -6
- data/lib/cmdx/retriers/decorrelated_jitter.rb +10 -5
- data/lib/cmdx/retriers/exponential.rb +10 -2
- data/lib/cmdx/retriers/fibonacci.rb +29 -12
- data/lib/cmdx/retriers.rb +17 -8
- data/lib/cmdx/retry.rb +20 -13
- data/lib/cmdx/runtime.rb +22 -40
- data/lib/cmdx/settings.rb +9 -9
- data/lib/cmdx/signal.rb +1 -1
- data/lib/cmdx/task.rb +90 -45
- data/lib/cmdx/telemetry.rb +52 -11
- data/lib/cmdx/util.rb +50 -4
- data/lib/cmdx/validators/absence.rb +1 -1
- data/lib/cmdx/validators/exclusion.rb +15 -15
- data/lib/cmdx/validators/format.rb +12 -4
- data/lib/cmdx/validators/inclusion.rb +15 -15
- data/lib/cmdx/validators/length.rb +5 -49
- data/lib/cmdx/validators/numeric.rb +5 -49
- data/lib/cmdx/validators/presence.rb +1 -1
- data/lib/cmdx/validators/validate.rb +7 -1
- data/lib/cmdx/validators.rb +21 -9
- data/lib/cmdx/version.rb +1 -1
- data/lib/cmdx/workflow.rb +28 -14
- data/lib/cmdx.rb +24 -0
- data/lib/generators/cmdx/templates/install.rb +96 -33
- data/mkdocs.yml +2 -0
- 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]
|
|
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,
|
|
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
|
|
248
|
-
# so an explicit nil is treated as not provided
|
|
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,
|
|
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
|
|
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,
|
|
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,
|
|
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.
|
|
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,
|
|
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
|
|
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 [
|
|
66
|
+
# @raise [UnknownEntryError] when `name` isn't registered
|
|
58
67
|
def lookup(name)
|
|
59
68
|
registry[name] || begin
|
|
60
|
-
raise
|
|
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 [
|
|
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
|
|
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
|
|
data/lib/cmdx/middlewares.rb
CHANGED
|
@@ -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,
|
|
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,
|
|
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
|
|
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,
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
131
|
+
|
|
132
|
+
chain.call
|
|
121
133
|
|
|
122
134
|
processed || begin
|
|
123
|
-
|
|
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,
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
7
|
-
# `prev_delay`. When no previous delay exists the upper bound
|
|
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
|
-
|
|
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
|
-
|
|
25
|
+
shift = [attempt, MAX_SHIFT].min
|
|
26
|
+
delay * (1 << shift)
|
|
19
27
|
end
|
|
20
28
|
|
|
21
29
|
end
|