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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +53 -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 +36 -52
- 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 +11 -10
- 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 +9 -5
- 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 +18 -3
- 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 +18 -17
- 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 +37 -10
- 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 +80 -39
- data/mkdocs.yml +1 -0
- metadata +1 -1
data/lib/cmdx/retry.rb
CHANGED
|
@@ -20,6 +20,7 @@ module CMDx
|
|
|
20
20
|
# `:decorrelated_jitter`) or custom
|
|
21
21
|
# @yieldparam attempt [Integer]
|
|
22
22
|
# @yieldparam delay [Float]
|
|
23
|
+
# @yieldparam prev_delay [Float, nil]
|
|
23
24
|
def initialize(exceptions, options = EMPTY_HASH, &block)
|
|
24
25
|
@exceptions = exceptions.flatten
|
|
25
26
|
@options = options.freeze
|
|
@@ -28,15 +29,17 @@ module CMDx
|
|
|
28
29
|
|
|
29
30
|
# Returns a new Retry layering `new_exceptions` and `new_options` onto the
|
|
30
31
|
# current one. Used for inheritance so subclasses extend rather than
|
|
31
|
-
# replace.
|
|
32
|
+
# replace. Returns `self` only when *every* override (exceptions, options,
|
|
33
|
+
# and block) is empty so option-only updates such as `retry_on(limit: 5)`
|
|
34
|
+
# still take effect.
|
|
32
35
|
#
|
|
33
36
|
# @param new_exceptions [Array<Class>]
|
|
34
37
|
# @param new_options [Hash{Symbol => Object}]
|
|
35
38
|
# @param block [#call, nil] replacement jitter callable (falls back to the prior block)
|
|
36
|
-
# @yield [attempt, delay] optional replacement jitter block
|
|
39
|
+
# @yield [attempt, delay, prev_delay] optional replacement jitter block
|
|
37
40
|
# @return [Retry]
|
|
38
41
|
def build(new_exceptions, new_options, &block)
|
|
39
|
-
return self if new_exceptions.empty?
|
|
42
|
+
return self if new_exceptions.empty? && new_options.empty? && block.nil?
|
|
40
43
|
|
|
41
44
|
merged_exceptions = exceptions | new_exceptions.flatten
|
|
42
45
|
merged_options = @options.merge(new_options)
|
|
@@ -66,6 +69,16 @@ module CMDx
|
|
|
66
69
|
|
|
67
70
|
# Sleeps `attempt`'s jittered/bounded delay. No-op when the base delay is zero.
|
|
68
71
|
#
|
|
72
|
+
# Custom jitter callables (registry, task Symbol method, `Proc` / block via
|
|
73
|
+
# `instance_exec` on the task, and other `#call`-ables) always receive
|
|
74
|
+
# `(attempt, delay, prev_delay)` so strategies share one shape; ignore
|
|
75
|
+
# `prev_delay` when you do not need decorrelated threading.
|
|
76
|
+
#
|
|
77
|
+
# Non-numeric or non-finite jitter results are sanitized to the base `delay`
|
|
78
|
+
# and the final sleep is always clamped to `[0, max_delay]` when `max_delay`
|
|
79
|
+
# is set, preventing self-DoS from a buggy jitter returning `Float::INFINITY`
|
|
80
|
+
# or a non-Numeric value.
|
|
81
|
+
#
|
|
69
82
|
# @param attempt [Integer] zero-based retry attempt number
|
|
70
83
|
# @param task [Task, nil] used as receiver for Symbol/Proc jitter strategies
|
|
71
84
|
# @param prev_delay [Float, nil] previous computed delay; only consumed by
|
|
@@ -85,18 +98,19 @@ module CMDx
|
|
|
85
98
|
if registry.key?(jitter)
|
|
86
99
|
registry.lookup(jitter).call(attempt, delay, prev_delay)
|
|
87
100
|
else
|
|
88
|
-
task.send(jitter, attempt, delay)
|
|
101
|
+
task.send(jitter, attempt, delay, prev_delay)
|
|
89
102
|
end
|
|
90
103
|
when Proc
|
|
91
|
-
task.instance_exec(attempt, delay, &jitter)
|
|
104
|
+
task.instance_exec(attempt, delay, prev_delay, &jitter)
|
|
92
105
|
else
|
|
93
106
|
if jitter.respond_to?(:call)
|
|
94
|
-
jitter.call(attempt, delay)
|
|
107
|
+
jitter.call(attempt, delay, prev_delay)
|
|
95
108
|
else
|
|
96
109
|
delay
|
|
97
110
|
end
|
|
98
111
|
end
|
|
99
112
|
|
|
113
|
+
d = delay unless d.is_a?(Numeric) && d.finite?
|
|
100
114
|
d = d.clamp(0, max_delay) if max_delay
|
|
101
115
|
Kernel.sleep(d) if d.positive?
|
|
102
116
|
d
|
|
@@ -126,13 +140,6 @@ module CMDx
|
|
|
126
140
|
|
|
127
141
|
private
|
|
128
142
|
|
|
129
|
-
# Resolves the retriers registry to consult for built-in jitter strategies.
|
|
130
|
-
# Prefers the task class's registry (so per-task `register(:retrier, ...)`
|
|
131
|
-
# overrides take effect) and falls back to the global configuration when
|
|
132
|
-
# no task is supplied.
|
|
133
|
-
#
|
|
134
|
-
# @param task [Task, nil]
|
|
135
|
-
# @return [Retriers]
|
|
136
143
|
def retriers_registry(task)
|
|
137
144
|
if task && task.class.respond_to?(:retriers)
|
|
138
145
|
task.class.retriers
|
data/lib/cmdx/runtime.rb
CHANGED
|
@@ -6,13 +6,14 @@ module CMDx
|
|
|
6
6
|
# retry), output verification, rollback on failure, result finalization,
|
|
7
7
|
# and teardown (freeze + chain clear).
|
|
8
8
|
#
|
|
9
|
-
# Signal propagation:
|
|
10
|
-
# `success!` / `skip!` / `fail!` /
|
|
11
|
-
#
|
|
12
|
-
# result as `:origin`); other `StandardError`s become
|
|
13
|
-
# the exception as `:cause
|
|
14
|
-
#
|
|
15
|
-
# originating result so `fault.task` points at the leaf that
|
|
9
|
+
# Signal propagation: `work` and the surrounding middleware/callback chain
|
|
10
|
+
# both run inside `catch(Signal::TAG)`, so `success!` / `skip!` / `fail!` /
|
|
11
|
+
# `throw!` halt cleanly from anywhere. Raised Faults become echoed signals
|
|
12
|
+
# (with the upstream result as `:origin`); other `StandardError`s become
|
|
13
|
+
# failed signals (with the exception as `:cause`). In strict mode,
|
|
14
|
+
# `execute!` re-raises from `ensure` using a {Fault} built from the
|
|
15
|
+
# deepest originating result, so `fault.task` points at the leaf that
|
|
16
|
+
# failed.
|
|
16
17
|
#
|
|
17
18
|
# @note Always used via the class method; never new Runtime manually.
|
|
18
19
|
# @see Task.execute
|
|
@@ -51,13 +52,12 @@ module CMDx
|
|
|
51
52
|
emit_telemetry(:task_started)
|
|
52
53
|
run_deprecation
|
|
53
54
|
run_lifecycle
|
|
54
|
-
finalize_result
|
|
55
|
-
raise_signal! if @strict
|
|
56
55
|
end
|
|
57
56
|
|
|
58
|
-
|
|
57
|
+
finalize_result
|
|
59
58
|
ensure
|
|
60
59
|
run_teardown
|
|
60
|
+
raise_signal!
|
|
61
61
|
end
|
|
62
62
|
|
|
63
63
|
private
|
|
@@ -71,10 +71,11 @@ module CMDx
|
|
|
71
71
|
end
|
|
72
72
|
|
|
73
73
|
def run_middlewares(&)
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
74
|
+
@signal = catch(Signal::TAG) do
|
|
75
|
+
middlewares = @task.class.middlewares
|
|
76
|
+
middlewares.empty? ? yield : middlewares.process(@task, &)
|
|
77
|
+
@signal # Return non-middleware signal
|
|
78
|
+
end
|
|
78
79
|
end
|
|
79
80
|
|
|
80
81
|
def run_deprecation
|
|
@@ -104,9 +105,9 @@ module CMDx
|
|
|
104
105
|
end
|
|
105
106
|
|
|
106
107
|
def raise_signal!
|
|
107
|
-
return unless @result
|
|
108
|
+
return unless @strict && @result&.failed?
|
|
108
109
|
|
|
109
|
-
cause = @
|
|
110
|
+
cause = @result.cause
|
|
110
111
|
raise cause if cause && !cause.is_a?(Fault)
|
|
111
112
|
|
|
112
113
|
raise Fault, @result.caused_failure, cause:
|
|
@@ -176,7 +177,7 @@ module CMDx
|
|
|
176
177
|
rescue Error => e
|
|
177
178
|
raise(e)
|
|
178
179
|
rescue StandardError => e
|
|
179
|
-
Signal.failed(
|
|
180
|
+
Signal.failed(Util.to_error_s(e), cause: e, metadata: @task.metadata)
|
|
180
181
|
end
|
|
181
182
|
end
|
|
182
183
|
|
data/lib/cmdx/settings.rb
CHANGED
|
@@ -50,7 +50,10 @@ module CMDx
|
|
|
50
50
|
end
|
|
51
51
|
end
|
|
52
52
|
|
|
53
|
-
# @return [Array<Symbol>] keys to exclude from
|
|
53
|
+
# @return [Array<Symbol>] keys to exclude from `Runtime` log output.
|
|
54
|
+
# Matched against {Result#to_h} keys. Common values for redaction:
|
|
55
|
+
# `:context` (may contain secrets / PII), `:cause` (raw exception),
|
|
56
|
+
# `:reason` (may embed exception messages from unhandled errors).
|
|
54
57
|
def log_exclusions
|
|
55
58
|
@options.fetch(:log_exclusions) do
|
|
56
59
|
CMDx.configuration.log_exclusions
|
|
@@ -64,14 +67,9 @@ module CMDx
|
|
|
64
67
|
end
|
|
65
68
|
end
|
|
66
69
|
|
|
67
|
-
#
|
|
68
|
-
# without affecting other tasks (or hitting `FrozenError` on the
|
|
69
|
-
# shared sentinel).
|
|
70
|
-
#
|
|
71
|
-
# @return [Array<Symbol, String>] task tags, surfaced on result hashes
|
|
70
|
+
# @return [Array<Symbol, String>] task tags
|
|
72
71
|
def tags
|
|
73
|
-
|
|
74
|
-
tags ? tags.dup : []
|
|
72
|
+
@options[:tags] || EMPTY_ARRAY
|
|
75
73
|
end
|
|
76
74
|
|
|
77
75
|
# @return [Boolean] whether this task's {Context} should raise on
|
|
@@ -83,7 +81,9 @@ module CMDx
|
|
|
83
81
|
end
|
|
84
82
|
end
|
|
85
83
|
|
|
86
|
-
# @return [
|
|
84
|
+
# @return [#call, nil] callable that produces a correlation id when invoked
|
|
85
|
+
# by Runtime at root-chain construction. Resolution order:
|
|
86
|
+
# task-level setting → {Configuration#correlation_id} → nil.
|
|
87
87
|
def correlation_id
|
|
88
88
|
@options.fetch(:correlation_id) do
|
|
89
89
|
CMDx.configuration.correlation_id
|
data/lib/cmdx/signal.rb
CHANGED
|
@@ -76,7 +76,7 @@ module CMDx
|
|
|
76
76
|
# @return [Signal] new instance mirroring `other`
|
|
77
77
|
# @raise [ArgumentError] when `other` is neither a Signal nor a Result
|
|
78
78
|
def echoed(other, **options)
|
|
79
|
-
raise ArgumentError, "
|
|
79
|
+
raise ArgumentError, "Signal.echoed expected a Result or Signal (got #{other.class})" unless other.is_a?(Result) || other.is_a?(Signal)
|
|
80
80
|
|
|
81
81
|
options[:origin] = other if other.is_a?(Result) && !options.key?(:origin)
|
|
82
82
|
new(other.state, other.status, **options, reason: other.reason)
|
data/lib/cmdx/task.rb
CHANGED
|
@@ -53,6 +53,9 @@ module CMDx
|
|
|
53
53
|
# @option options [Array<Symbol>] :log_exclusions (see {Settings#initialize})
|
|
54
54
|
# @option options [Array<Symbol, String>] :tags (see {Settings#initialize})
|
|
55
55
|
# @option options [Boolean] :strict_context (see {Settings#initialize})
|
|
56
|
+
# @option options [#call] :correlation_id callable returning a String id
|
|
57
|
+
# resolved once by {Runtime} when the root chain is acquired; surfaces
|
|
58
|
+
# as `result.xid` (see {Settings#correlation_id})
|
|
56
59
|
# @return [Settings]
|
|
57
60
|
def settings(options = EMPTY_HASH)
|
|
58
61
|
@settings ||=
|
|
@@ -190,7 +193,12 @@ module CMDx
|
|
|
190
193
|
inputs.register(self, ...)
|
|
191
194
|
when :output
|
|
192
195
|
outputs.register(...)
|
|
193
|
-
else
|
|
196
|
+
else
|
|
197
|
+
raise ArgumentError, <<~MSG.chomp
|
|
198
|
+
unknown registry type #{type.inspect};
|
|
199
|
+
expected one of [:middleware, :callback, :coercion, :validator, :executor, :merger, :retrier, :deprecator, :input, :output].
|
|
200
|
+
See https://drexed.github.io/cmdx/configuration/#registrations-register-deregister
|
|
201
|
+
MSG
|
|
194
202
|
end
|
|
195
203
|
end
|
|
196
204
|
|
|
@@ -221,7 +229,12 @@ module CMDx
|
|
|
221
229
|
inputs.deregister(self, ...)
|
|
222
230
|
when :output
|
|
223
231
|
outputs.deregister(...)
|
|
224
|
-
else
|
|
232
|
+
else
|
|
233
|
+
raise ArgumentError, <<~MSG.chomp
|
|
234
|
+
unknown registry type #{type.inspect};
|
|
235
|
+
expected one of [:middleware, :callback, :coercion, :validator, :executor, :merger, :retrier, :deprecator, :input, :output].
|
|
236
|
+
See https://drexed.github.io/cmdx/configuration/#registrations-register-deregister
|
|
237
|
+
MSG
|
|
225
238
|
end
|
|
226
239
|
end
|
|
227
240
|
|
|
@@ -280,6 +293,8 @@ module CMDx
|
|
|
280
293
|
alias input inputs
|
|
281
294
|
|
|
282
295
|
# Declares optional inputs (shorthand for `inputs ..., required: false`).
|
|
296
|
+
# An explicit `required:` in `options` is ignored — use {.inputs} when
|
|
297
|
+
# you need to set the flag dynamically.
|
|
283
298
|
#
|
|
284
299
|
# @param names [Array<Symbol>]
|
|
285
300
|
# @param options [Hash{Symbol => Object}] see {Input#initialize}
|
|
@@ -296,10 +311,12 @@ module CMDx
|
|
|
296
311
|
# @option options [Object] :validate (see {Validators#extract})
|
|
297
312
|
# @yield nested-input DSL block (see {Inputs::ChildBuilder})
|
|
298
313
|
def optional(*names, **options, &)
|
|
299
|
-
register(:input, *names, required: false,
|
|
314
|
+
register(:input, *names, **options, required: false, &)
|
|
300
315
|
end
|
|
301
316
|
|
|
302
317
|
# Declares required inputs (shorthand for `inputs ..., required: true`).
|
|
318
|
+
# An explicit `required:` in `options` is ignored — use {.inputs} when
|
|
319
|
+
# you need to set the flag dynamically.
|
|
303
320
|
#
|
|
304
321
|
# @param names [Array<Symbol>]
|
|
305
322
|
# @param options [Hash{Symbol => Object}] see {Input#initialize}
|
|
@@ -316,7 +333,7 @@ module CMDx
|
|
|
316
333
|
# @option options [Object] :validate (see {Validators#extract})
|
|
317
334
|
# @yield nested-input DSL block (see {Inputs::ChildBuilder})
|
|
318
335
|
def required(*names, **options, &)
|
|
319
|
-
register(:input, *names, required: true,
|
|
336
|
+
register(:input, *names, **options, required: true, &)
|
|
320
337
|
end
|
|
321
338
|
|
|
322
339
|
# @return [Hash{Symbol => Hash}] serialized input definitions
|
|
@@ -382,23 +399,29 @@ module CMDx
|
|
|
382
399
|
|
|
383
400
|
private
|
|
384
401
|
|
|
385
|
-
# @param input [Input] defines `##{input.accessor_name}` when not already taken
|
|
386
|
-
# @return [void]
|
|
387
|
-
# @raise [DefinitionError] when the accessor name collides
|
|
388
402
|
def define_input_reader(input)
|
|
389
403
|
accessor = input.accessor_name
|
|
390
404
|
|
|
391
405
|
if method_defined?(accessor) || private_method_defined?(accessor)
|
|
392
|
-
raise DefinitionError,
|
|
393
|
-
|
|
406
|
+
raise DefinitionError, <<~MSG.chomp
|
|
407
|
+
Cannot define input #{accessor.inspect}: ##{accessor} is already defined on #{self}.
|
|
408
|
+
|
|
409
|
+
Typical cause #1: there's a method or private method with the same name as the input.
|
|
410
|
+
|
|
411
|
+
Typical cause #2: two sibling `inputs` groups declare the same nested name
|
|
412
|
+
(for example `inputs :a, :b do ... required :x ... end`, which defines `:x` twice).
|
|
413
|
+
|
|
414
|
+
Fix: rename with `:as`, `:prefix`, or `:suffix`, or give each parent its own
|
|
415
|
+
`inputs ... do ... end` block so nested accessors do not collide.
|
|
416
|
+
|
|
417
|
+
https://drexed.github.io/cmdx/inputs/definitions
|
|
418
|
+
MSG
|
|
394
419
|
end
|
|
395
420
|
|
|
396
421
|
define_method(accessor) { instance_variable_get(input.ivar_name) }
|
|
397
422
|
input.children.each { |child| define_input_reader(child) }
|
|
398
423
|
end
|
|
399
424
|
|
|
400
|
-
# @param input [Input] removes `##{input.accessor_name}` if defined on this class
|
|
401
|
-
# @return [void]
|
|
402
425
|
def undefine_input_reader(input)
|
|
403
426
|
accessor = input.accessor_name
|
|
404
427
|
undef_method(accessor) if method_defined?(accessor)
|
|
@@ -449,65 +472,87 @@ module CMDx
|
|
|
449
472
|
# @return [void]
|
|
450
473
|
# @raise [ImplementationError] when the subclass doesn't override
|
|
451
474
|
def work
|
|
452
|
-
raise ImplementationError,
|
|
475
|
+
raise ImplementationError, <<~MSG.chomp
|
|
476
|
+
#{self.class} must implement #work.
|
|
477
|
+
See https://drexed.github.io/cmdx/basics/setup/#structure
|
|
478
|
+
MSG
|
|
453
479
|
end
|
|
454
480
|
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
# Signals a successful halt.
|
|
481
|
+
# Halts `#work` early with a successful outcome. Any `sigdata` is merged
|
|
482
|
+
# onto {#metadata} before the signal is thrown.
|
|
458
483
|
#
|
|
459
|
-
# @param reason [String, nil]
|
|
460
|
-
# @param sigdata [Hash{Symbol => Object}]
|
|
461
|
-
# @
|
|
462
|
-
# @
|
|
463
|
-
# @raise [FrozenError] when the task has already been frozen (post-execution)
|
|
464
|
-
# @note Must be called from inside `work` (inside Runtime's `catch(:cmdx_signal)`).
|
|
484
|
+
# @param reason [String, nil] human-readable explanation surfaced on the {Result}
|
|
485
|
+
# @param sigdata [Hash{Symbol => Object}] extra metadata merged into {#metadata}
|
|
486
|
+
# @return [void] never returns; throws {Signal::TAG}
|
|
487
|
+
# @raise [FrozenTaskError] when the task has already been executed
|
|
465
488
|
def success!(reason = nil, **sigdata)
|
|
466
|
-
|
|
489
|
+
if frozen?
|
|
490
|
+
raise FrozenTaskError, <<~MSG.chomp
|
|
491
|
+
cannot call :success! on #{self.class}; the task has already been executed and frozen.
|
|
492
|
+
See https://drexed.github.io/cmdx/outcomes/result/#lifecycle-predicates
|
|
493
|
+
MSG
|
|
494
|
+
end
|
|
467
495
|
|
|
468
496
|
metadata.merge!(sigdata) unless sigdata.empty?
|
|
469
497
|
throw(Signal::TAG, Signal.success(reason, metadata:))
|
|
470
498
|
end
|
|
471
499
|
|
|
472
|
-
#
|
|
500
|
+
# Halts `#work` and marks the {Result} as `skipped`. Any `sigdata` is merged
|
|
501
|
+
# onto {#metadata} before the signal is thrown.
|
|
473
502
|
#
|
|
474
|
-
# @param reason [String, nil]
|
|
475
|
-
# @param sigdata [Hash{Symbol => Object}]
|
|
476
|
-
# @
|
|
477
|
-
# @
|
|
478
|
-
# @raise [FrozenError]
|
|
503
|
+
# @param reason [String, nil] human-readable explanation surfaced on the {Result}
|
|
504
|
+
# @param sigdata [Hash{Symbol => Object}] extra metadata merged into {#metadata}
|
|
505
|
+
# @return [void] never returns; throws {Signal::TAG}
|
|
506
|
+
# @raise [FrozenTaskError] when the task has already been executed
|
|
479
507
|
def skip!(reason = nil, **sigdata)
|
|
480
|
-
|
|
508
|
+
if frozen?
|
|
509
|
+
raise FrozenTaskError, <<~MSG.chomp
|
|
510
|
+
cannot call :skip! on #{self.class}; the task has already been executed and frozen.
|
|
511
|
+
See https://drexed.github.io/cmdx/outcomes/result/#lifecycle-predicates
|
|
512
|
+
MSG
|
|
513
|
+
end
|
|
481
514
|
|
|
482
515
|
metadata.merge!(sigdata) unless sigdata.empty?
|
|
483
516
|
throw(Signal::TAG, Signal.skipped(reason, metadata:))
|
|
484
517
|
end
|
|
485
518
|
|
|
486
|
-
#
|
|
487
|
-
#
|
|
519
|
+
# Halts `#work` and marks the {Result} as `failed`. Captures the current
|
|
520
|
+
# caller frames for the {Fault} backtrace and merges any `sigdata` onto
|
|
521
|
+
# {#metadata} before the signal is thrown.
|
|
488
522
|
#
|
|
489
|
-
# @param reason [String, nil]
|
|
490
|
-
# @param sigdata [Hash{Symbol => Object}]
|
|
491
|
-
# @
|
|
492
|
-
# @
|
|
493
|
-
# @raise [FrozenError]
|
|
523
|
+
# @param reason [String, nil] human-readable explanation surfaced on the {Result}/{Fault}
|
|
524
|
+
# @param sigdata [Hash{Symbol => Object}] extra metadata merged into {#metadata}
|
|
525
|
+
# @return [void] never returns; throws {Signal::TAG}
|
|
526
|
+
# @raise [FrozenTaskError] when the task has already been executed
|
|
494
527
|
def fail!(reason = nil, **sigdata)
|
|
495
|
-
|
|
528
|
+
if frozen?
|
|
529
|
+
raise FrozenTaskError, <<~MSG.chomp
|
|
530
|
+
cannot call :fail! on #{self.class}; the task has already been executed and frozen.
|
|
531
|
+
See https://drexed.github.io/cmdx/outcomes/result/#lifecycle-predicates
|
|
532
|
+
MSG
|
|
533
|
+
end
|
|
496
534
|
|
|
497
535
|
metadata.merge!(sigdata) unless sigdata.empty?
|
|
498
536
|
throw(Signal::TAG, Signal.failed(reason, metadata:, backtrace: caller_locations(1)))
|
|
499
537
|
end
|
|
500
538
|
|
|
501
|
-
#
|
|
502
|
-
# `other`
|
|
539
|
+
# Echoes another {Result} or {Signal}'s failure outcome from this task. A
|
|
540
|
+
# no-op when `other` is not failed, letting callers conditionally bubble up
|
|
541
|
+
# nested failures without branching. Captures caller frames for the {Fault}
|
|
542
|
+
# backtrace and merges any `sigdata` onto {#metadata} before the signal is
|
|
543
|
+
# thrown.
|
|
503
544
|
#
|
|
504
|
-
# @param other [Result]
|
|
505
|
-
# @param sigdata [Hash{Symbol => Object}]
|
|
506
|
-
# @
|
|
507
|
-
# @
|
|
508
|
-
# @raise [FrozenError]
|
|
545
|
+
# @param other [Result, Signal] upstream outcome to mirror
|
|
546
|
+
# @param sigdata [Hash{Symbol => Object}] extra metadata merged into {#metadata}
|
|
547
|
+
# @return [void, nil] returns `nil` when `other` is not failed; otherwise throws {Signal::TAG}
|
|
548
|
+
# @raise [FrozenTaskError] when the task has already been executed
|
|
509
549
|
def throw!(other, **sigdata)
|
|
510
|
-
|
|
550
|
+
if frozen?
|
|
551
|
+
raise FrozenTaskError, <<~MSG.chomp
|
|
552
|
+
cannot call :throw! on #{self.class}; the task has already been executed and frozen.
|
|
553
|
+
See https://drexed.github.io/cmdx/outcomes/result/#lifecycle-predicates
|
|
554
|
+
MSG
|
|
555
|
+
end
|
|
511
556
|
|
|
512
557
|
return unless other.failed?
|
|
513
558
|
|
data/lib/cmdx/telemetry.rb
CHANGED
|
@@ -57,11 +57,17 @@ module CMDx
|
|
|
57
57
|
subscriber = callable || block
|
|
58
58
|
|
|
59
59
|
if callable && block
|
|
60
|
-
raise ArgumentError, "provide either a callable or a block, not both"
|
|
60
|
+
raise ArgumentError, "subscriber: provide either a callable or a block, not both"
|
|
61
61
|
elsif !subscriber.respond_to?(:call)
|
|
62
|
-
raise ArgumentError,
|
|
62
|
+
raise ArgumentError, <<~MSG.chomp
|
|
63
|
+
subscriber must respond to #call (got #{subscriber.class}).
|
|
64
|
+
See https://drexed.github.io/cmdx/configuration/#telemetry
|
|
65
|
+
MSG
|
|
63
66
|
elsif !EVENTS.include?(event)
|
|
64
|
-
raise ArgumentError,
|
|
67
|
+
raise ArgumentError, <<~MSG.chomp
|
|
68
|
+
unknown telemetry event #{event.inspect}, must be one of #{EVENTS.inspect}.
|
|
69
|
+
See https://drexed.github.io/cmdx/configuration/#telemetry
|
|
70
|
+
MSG
|
|
65
71
|
end
|
|
66
72
|
|
|
67
73
|
(registry[event] ||= []) << subscriber
|
|
@@ -74,14 +80,20 @@ module CMDx
|
|
|
74
80
|
# @param event [Symbol] one of {EVENTS}
|
|
75
81
|
# @param callable [#call] the subscriber to remove
|
|
76
82
|
# @return [Telemetry] self for chaining
|
|
77
|
-
# @raise [
|
|
83
|
+
# @raise [UnknownEntryError] when `event` is unknown
|
|
78
84
|
def unsubscribe(event, callable)
|
|
79
|
-
|
|
85
|
+
unless EVENTS.include?(event)
|
|
86
|
+
raise UnknownEntryError, <<~MSG.chomp
|
|
87
|
+
unknown telemetry event #{event.inspect}, must be one of #{EVENTS.inspect}.
|
|
88
|
+
See https://drexed.github.io/cmdx/configuration/#telemetry
|
|
89
|
+
MSG
|
|
90
|
+
end
|
|
80
91
|
|
|
81
|
-
|
|
92
|
+
if (subscribers = registry[event])
|
|
93
|
+
subscribers.delete(callable)
|
|
94
|
+
registry.delete(event) if subscribers.empty?
|
|
95
|
+
end
|
|
82
96
|
|
|
83
|
-
registry[event].delete(callable)
|
|
84
|
-
registry.delete(event) if registry[event].empty?
|
|
85
97
|
self
|
|
86
98
|
end
|
|
87
99
|
|
|
@@ -91,6 +103,18 @@ module CMDx
|
|
|
91
103
|
registry.key?(event)
|
|
92
104
|
end
|
|
93
105
|
|
|
106
|
+
# @param event [Symbol]
|
|
107
|
+
# @return [#call]
|
|
108
|
+
# @raise [UnknownEntryError] when `event` isn't registered
|
|
109
|
+
def lookup(event)
|
|
110
|
+
registry[event] || begin
|
|
111
|
+
raise UnknownEntryError, <<~MSG.chomp
|
|
112
|
+
unknown telemetry event #{event.inspect}; registered: #{registry.keys.inspect}.
|
|
113
|
+
See https://drexed.github.io/cmdx/configuration/#telemetry
|
|
114
|
+
MSG
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
94
118
|
# @return [Boolean]
|
|
95
119
|
def empty?
|
|
96
120
|
registry.empty?
|
|
@@ -113,9 +137,12 @@ module CMDx
|
|
|
113
137
|
# @param payload [Event]
|
|
114
138
|
# @return [void]
|
|
115
139
|
def emit(event, payload)
|
|
116
|
-
return
|
|
140
|
+
return if empty?
|
|
141
|
+
|
|
142
|
+
subscribers = lookup(event)
|
|
143
|
+
return if subscribers.nil? || subscribers.empty?
|
|
117
144
|
|
|
118
|
-
subscribers.each { |
|
|
145
|
+
subscribers.each { |callable| callable.call(payload) }
|
|
119
146
|
end
|
|
120
147
|
|
|
121
148
|
end
|
data/lib/cmdx/util.rb
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module CMDx
|
|
4
|
-
# Shared helpers for
|
|
5
|
-
#
|
|
6
|
-
# booleans, symbols (method names), procs, and call-ables into a truth value.
|
|
4
|
+
# Shared helpers for `:if` / `:unless` gates, recursive hash merge/dup, and
|
|
5
|
+
# related tree utilities used across tasks, context, and i18n.
|
|
7
6
|
module Util
|
|
8
7
|
|
|
9
8
|
extend self
|
|
@@ -28,7 +27,8 @@ module CMDx
|
|
|
28
27
|
else
|
|
29
28
|
return condition.call(receiver, *args) if condition.respond_to?(:call)
|
|
30
29
|
|
|
31
|
-
raise ArgumentError,
|
|
30
|
+
raise ArgumentError,
|
|
31
|
+
"condition must be a Symbol, Proc, or respond to #call (got #{condition.class})"
|
|
32
32
|
end
|
|
33
33
|
end
|
|
34
34
|
|
|
@@ -69,5 +69,51 @@ module CMDx
|
|
|
69
69
|
unless?(condition_unless, receiver, *args)
|
|
70
70
|
end
|
|
71
71
|
|
|
72
|
+
# Recursively merges two Hash-like trees. When both values at a key are
|
|
73
|
+
# Hashes, they merge recursively; otherwise the right-hand value wins
|
|
74
|
+
# (last-write-wins). When either top-level operand is not a Hash, returns
|
|
75
|
+
# `rhs` unchanged — useful when folding unknown YAML roots.
|
|
76
|
+
#
|
|
77
|
+
# @param lhs [Object] left tree (typically a Hash)
|
|
78
|
+
# @param rhs [Object] right tree (typically a Hash)
|
|
79
|
+
# @return [Object] merged Hash or `rhs` when a Hash-only merge is impossible
|
|
80
|
+
def deep_merge(lhs, rhs)
|
|
81
|
+
return rhs unless lhs.is_a?(Hash) && rhs.is_a?(Hash)
|
|
82
|
+
|
|
83
|
+
lhs.merge(rhs) { |_key, l, r| deep_merge(l, r) }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Returns a deep copy of `value`. Immutable scalars (`Numeric`, `Symbol`,
|
|
87
|
+
# booleans, `nil`) are returned as-is; `Hash` and `Array` are walked
|
|
88
|
+
# recursively; other objects use `#dup`, falling back to the original when
|
|
89
|
+
# `#dup` raises.
|
|
90
|
+
#
|
|
91
|
+
# @param value [Object]
|
|
92
|
+
# @return [Object]
|
|
93
|
+
def deep_dup(value)
|
|
94
|
+
case value
|
|
95
|
+
when Numeric, Symbol, TrueClass, FalseClass, NilClass
|
|
96
|
+
value
|
|
97
|
+
when Hash
|
|
98
|
+
value.each_with_object({}) { |(k, v), acc| acc[k] = deep_dup(v) }
|
|
99
|
+
when Array
|
|
100
|
+
value.map { |e| deep_dup(e) }
|
|
101
|
+
else
|
|
102
|
+
begin
|
|
103
|
+
value.dup
|
|
104
|
+
rescue StandardError
|
|
105
|
+
value
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Returns a string representation of `error` in the format `[Class] Message`.
|
|
111
|
+
#
|
|
112
|
+
# @param error [Exception]
|
|
113
|
+
# @return [String]
|
|
114
|
+
def to_error_s(error)
|
|
115
|
+
"[#{error.class}] #{error.message}"
|
|
116
|
+
end
|
|
117
|
+
|
|
72
118
|
end
|
|
73
119
|
end
|
|
@@ -19,22 +19,29 @@ module CMDx
|
|
|
19
19
|
# @raise [ArgumentError] when neither `:in` nor `:within` is given
|
|
20
20
|
def call(value, options = EMPTY_HASH)
|
|
21
21
|
values = options[:in] || options[:within]
|
|
22
|
-
|
|
22
|
+
if values.nil?
|
|
23
|
+
raise ArgumentError, <<~MSG.chomp
|
|
24
|
+
exclusion validator requires :in or :within (got #{options.keys.inspect}).
|
|
25
|
+
See https://drexed.github.io/cmdx/inputs/validations/#exclusion
|
|
26
|
+
MSG
|
|
27
|
+
elsif values.is_a?(Hash)
|
|
28
|
+
raise ArgumentError, <<~MSG.chomp
|
|
29
|
+
exclusion validator :in/:within does not accept a Hash; pass an Array,
|
|
30
|
+
Set, Range, or other Enumerable (e.g. `#{values.inspect}.keys`).
|
|
31
|
+
See https://drexed.github.io/cmdx/inputs/validations/#exclusion
|
|
32
|
+
MSG
|
|
33
|
+
end
|
|
23
34
|
|
|
24
35
|
if values.is_a?(Range)
|
|
25
36
|
within_failure(values.begin, values.end, options) if values.cover?(value)
|
|
26
|
-
|
|
27
|
-
|
|
37
|
+
else
|
|
38
|
+
enum = values.is_a?(Enumerable) ? values : [values]
|
|
39
|
+
of_failure(enum, options) if enum.any? { |v| v === value }
|
|
28
40
|
end
|
|
29
41
|
end
|
|
30
42
|
|
|
31
43
|
private
|
|
32
44
|
|
|
33
|
-
# @param values [Enumerable] collection rendered into the failure message
|
|
34
|
-
# @param options [Hash{Symbol => Object}]
|
|
35
|
-
# @option options [String] :of_message
|
|
36
|
-
# @option options [String] :message
|
|
37
|
-
# @return [Validators::Failure]
|
|
38
45
|
def of_failure(values, options)
|
|
39
46
|
values = values.map(&:inspect).join(", ")
|
|
40
47
|
message = options[:of_message] || options[:message]
|
|
@@ -43,13 +50,6 @@ module CMDx
|
|
|
43
50
|
Failure.new(message || I18nProxy.t("cmdx.validators.exclusion.of", values:))
|
|
44
51
|
end
|
|
45
52
|
|
|
46
|
-
# @param min [Object] range/exclusion lower bound
|
|
47
|
-
# @param max [Object] range/exclusion upper bound
|
|
48
|
-
# @param options [Hash{Symbol => Object}]
|
|
49
|
-
# @option options [String] :in_message
|
|
50
|
-
# @option options [String] :within_message
|
|
51
|
-
# @option options [String] :message
|
|
52
|
-
# @return [Validators::Failure]
|
|
53
53
|
def within_failure(min, max, options)
|
|
54
54
|
message = options[:in_message] || options[:within_message] || options[:message]
|
|
55
55
|
message %= { min:, max: } unless message.nil?
|