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/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,13 +58,16 @@ 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
|
|
|
@@ -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
|
-
|
|
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
|
|
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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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,
|
|
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
|
|
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
|
|
66
|
+
registry.key?(name)
|
|
64
67
|
end
|
|
65
68
|
|
|
66
69
|
# @param name [Symbol]
|
|
67
70
|
# @return [#call] the registered retrier
|
|
68
|
-
# @raise [
|
|
71
|
+
# @raise [UnknownEntryError] when `name` isn't registered
|
|
69
72
|
def lookup(name)
|
|
70
73
|
registry[name] || begin
|
|
71
|
-
raise
|
|
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 [
|
|
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
|
|
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
|
|