cmdx 1.19.0 → 1.21.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/.DS_Store +0 -0
- data/CHANGELOG.md +82 -16
- data/README.md +1 -1
- data/lib/cmdx/attribute.rb +82 -19
- data/lib/cmdx/attribute_registry.rb +79 -8
- data/lib/cmdx/attribute_value.rb +2 -2
- data/lib/cmdx/callback_registry.rb +60 -26
- data/lib/cmdx/chain.rb +34 -5
- data/lib/cmdx/coercion_registry.rb +42 -20
- data/lib/cmdx/coercions/array.rb +2 -2
- data/lib/cmdx/coercions/big_decimal.rb +1 -1
- data/lib/cmdx/coercions/boolean.rb +2 -2
- data/lib/cmdx/coercions/complex.rb +1 -1
- data/lib/cmdx/coercions/date.rb +1 -1
- data/lib/cmdx/coercions/date_time.rb +1 -1
- data/lib/cmdx/coercions/float.rb +1 -1
- data/lib/cmdx/coercions/hash.rb +1 -1
- data/lib/cmdx/coercions/integer.rb +1 -1
- data/lib/cmdx/coercions/rational.rb +1 -1
- data/lib/cmdx/coercions/string.rb +1 -1
- data/lib/cmdx/coercions/symbol.rb +1 -1
- data/lib/cmdx/coercions/time.rb +1 -1
- data/lib/cmdx/configuration.rb +38 -0
- data/lib/cmdx/context.rb +11 -8
- data/lib/cmdx/deprecator.rb +27 -14
- data/lib/cmdx/errors.rb +3 -4
- data/lib/cmdx/exception.rb +7 -0
- data/lib/cmdx/executor.rb +80 -53
- data/lib/cmdx/identifier.rb +4 -6
- data/lib/cmdx/locale.rb +32 -9
- data/lib/cmdx/middleware_registry.rb +43 -23
- data/lib/cmdx/middlewares/correlate.rb +4 -2
- data/lib/cmdx/middlewares/runtime.rb +18 -3
- data/lib/cmdx/middlewares/timeout.rb +11 -10
- data/lib/cmdx/parallelizer.rb +100 -0
- data/lib/cmdx/pipeline.rb +42 -23
- data/lib/cmdx/railtie.rb +1 -1
- data/lib/cmdx/result.rb +91 -19
- data/lib/cmdx/retry.rb +166 -0
- data/lib/cmdx/settings.rb +226 -0
- data/lib/cmdx/task.rb +62 -65
- data/lib/cmdx/utils/format.rb +17 -1
- data/lib/cmdx/utils/normalize.rb +52 -0
- data/lib/cmdx/utils/wrap.rb +38 -0
- data/lib/cmdx/validator_registry.rb +44 -19
- data/lib/cmdx/validators/absence.rb +1 -1
- data/lib/cmdx/validators/exclusion.rb +2 -2
- data/lib/cmdx/validators/format.rb +1 -1
- data/lib/cmdx/validators/inclusion.rb +2 -2
- data/lib/cmdx/validators/length.rb +1 -1
- data/lib/cmdx/validators/numeric.rb +1 -1
- data/lib/cmdx/validators/presence.rb +1 -1
- data/lib/cmdx/version.rb +1 -1
- data/lib/cmdx/workflow.rb +17 -0
- data/lib/cmdx.rb +12 -0
- data/lib/generators/cmdx/templates/install.rb +20 -5
- data/lib/locales/af.yml +2 -0
- data/lib/locales/ar.yml +2 -0
- data/lib/locales/az.yml +2 -0
- data/lib/locales/be.yml +2 -0
- data/lib/locales/bg.yml +2 -0
- data/lib/locales/bn.yml +2 -0
- data/lib/locales/bs.yml +2 -0
- data/lib/locales/ca.yml +2 -0
- data/lib/locales/cnr.yml +2 -0
- data/lib/locales/cs.yml +2 -0
- data/lib/locales/cy.yml +2 -0
- data/lib/locales/da.yml +2 -0
- data/lib/locales/de.yml +2 -0
- data/lib/locales/dz.yml +2 -0
- data/lib/locales/el.yml +2 -0
- data/lib/locales/en.yml +2 -0
- data/lib/locales/eo.yml +2 -0
- data/lib/locales/es.yml +2 -0
- data/lib/locales/et.yml +2 -0
- data/lib/locales/eu.yml +2 -0
- data/lib/locales/fa.yml +2 -0
- data/lib/locales/fi.yml +2 -0
- data/lib/locales/fr.yml +2 -0
- data/lib/locales/fy.yml +2 -0
- data/lib/locales/gd.yml +2 -0
- data/lib/locales/gl.yml +2 -0
- data/lib/locales/he.yml +2 -0
- data/lib/locales/hi.yml +2 -0
- data/lib/locales/hr.yml +2 -0
- data/lib/locales/hu.yml +2 -0
- data/lib/locales/hy.yml +2 -0
- data/lib/locales/id.yml +2 -0
- data/lib/locales/is.yml +2 -0
- data/lib/locales/it.yml +2 -0
- data/lib/locales/ja.yml +2 -0
- data/lib/locales/ka.yml +2 -0
- data/lib/locales/kk.yml +2 -0
- data/lib/locales/km.yml +2 -0
- data/lib/locales/kn.yml +2 -0
- data/lib/locales/ko.yml +2 -0
- data/lib/locales/lb.yml +2 -0
- data/lib/locales/lo.yml +2 -0
- data/lib/locales/lt.yml +2 -0
- data/lib/locales/lv.yml +2 -0
- data/lib/locales/mg.yml +2 -0
- data/lib/locales/mk.yml +2 -0
- data/lib/locales/ml.yml +2 -0
- data/lib/locales/mn.yml +2 -0
- data/lib/locales/mr-IN.yml +2 -0
- data/lib/locales/ms.yml +2 -0
- data/lib/locales/nb.yml +2 -0
- data/lib/locales/ne.yml +2 -0
- data/lib/locales/nl.yml +2 -0
- data/lib/locales/nn.yml +2 -0
- data/lib/locales/oc.yml +2 -0
- data/lib/locales/or.yml +2 -0
- data/lib/locales/pa.yml +2 -0
- data/lib/locales/pl.yml +2 -0
- data/lib/locales/pt.yml +2 -0
- data/lib/locales/rm.yml +2 -0
- data/lib/locales/ro.yml +2 -0
- data/lib/locales/ru.yml +2 -0
- data/lib/locales/sc.yml +2 -0
- data/lib/locales/sk.yml +2 -0
- data/lib/locales/sl.yml +2 -0
- data/lib/locales/sq.yml +2 -0
- data/lib/locales/sr.yml +2 -0
- data/lib/locales/st.yml +2 -0
- data/lib/locales/sv.yml +2 -0
- data/lib/locales/sw.yml +2 -0
- data/lib/locales/ta.yml +2 -0
- data/lib/locales/te.yml +2 -0
- data/lib/locales/th.yml +2 -0
- data/lib/locales/tl.yml +2 -0
- data/lib/locales/tr.yml +2 -0
- data/lib/locales/tt.yml +2 -0
- data/lib/locales/ug.yml +2 -0
- data/lib/locales/uk.yml +2 -0
- data/lib/locales/ur.yml +2 -0
- data/lib/locales/uz.yml +2 -0
- data/lib/locales/vi.yml +2 -0
- data/lib/locales/wo.yml +2 -0
- data/lib/locales/zh-CN.yml +2 -0
- data/lib/locales/zh-HK.yml +2 -0
- data/lib/locales/zh-TW.yml +2 -0
- data/lib/locales/zh-YUE.yml +2 -0
- data/mkdocs.yml +5 -1
- metadata +6 -15
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CMDx
|
|
4
|
+
# Bounded thread pool that processes items concurrently.
|
|
5
|
+
#
|
|
6
|
+
# Distributes work across a fixed number of threads using a queue,
|
|
7
|
+
# collecting results in submission order.
|
|
8
|
+
class Parallelizer
|
|
9
|
+
|
|
10
|
+
# Returns the items to process.
|
|
11
|
+
#
|
|
12
|
+
# @return [Array] the items to process
|
|
13
|
+
#
|
|
14
|
+
# @example
|
|
15
|
+
# parallelizer.items # => [1, 2, 3]
|
|
16
|
+
#
|
|
17
|
+
# @rbs @items: Array[untyped]
|
|
18
|
+
attr_reader :items
|
|
19
|
+
|
|
20
|
+
# Returns the number of threads in the pool.
|
|
21
|
+
#
|
|
22
|
+
# @return [Integer] the thread pool size
|
|
23
|
+
#
|
|
24
|
+
# @example
|
|
25
|
+
# parallelizer.pool_size # => 4
|
|
26
|
+
#
|
|
27
|
+
# @rbs @pool_size: Integer
|
|
28
|
+
attr_reader :pool_size
|
|
29
|
+
|
|
30
|
+
# Creates a new Parallelizer instance.
|
|
31
|
+
#
|
|
32
|
+
# @param items [Array] the items to process concurrently
|
|
33
|
+
# @param pool_size [Integer] number of threads (defaults to item count)
|
|
34
|
+
#
|
|
35
|
+
# @return [Parallelizer] a new parallelizer instance
|
|
36
|
+
#
|
|
37
|
+
# @example
|
|
38
|
+
# Parallelizer.new([1, 2, 3], pool_size: 2)
|
|
39
|
+
#
|
|
40
|
+
# @rbs (Array[untyped] items, ?pool_size: Integer) -> void
|
|
41
|
+
def initialize(items, pool_size: nil)
|
|
42
|
+
@items = items
|
|
43
|
+
@pool_size = Integer(pool_size || items.size)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Processes items concurrently and returns results in submission order.
|
|
47
|
+
#
|
|
48
|
+
# @param items [Array] the items to process concurrently
|
|
49
|
+
# @param pool_size [Integer] number of threads (defaults to item count)
|
|
50
|
+
#
|
|
51
|
+
# @yield [item] block called for each item in a worker thread
|
|
52
|
+
# @yieldparam item [Object] an item from the items array
|
|
53
|
+
# @yieldreturn [Object] the result for this item
|
|
54
|
+
#
|
|
55
|
+
# @return [Array] results in the same order as input items
|
|
56
|
+
#
|
|
57
|
+
# @example
|
|
58
|
+
# Parallelizer.call([1, 2, 3], pool_size: 2) { |n| n * 10 }
|
|
59
|
+
# # => [10, 20, 30]
|
|
60
|
+
#
|
|
61
|
+
# @rbs [T, R] (Array[T] items, ?pool_size: Integer) { (T) -> R } -> Array[R]
|
|
62
|
+
def self.call(items, pool_size: nil, &block)
|
|
63
|
+
new(items, pool_size:).call(&block)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Distributes items across the thread pool and returns results
|
|
67
|
+
# in submission order.
|
|
68
|
+
#
|
|
69
|
+
# @yield [item] block called for each item in a worker thread
|
|
70
|
+
# @yieldparam item [Object] an item from the items array
|
|
71
|
+
# @yieldreturn [Object] the result for this item
|
|
72
|
+
#
|
|
73
|
+
# @return [Array] results in the same order as input items
|
|
74
|
+
#
|
|
75
|
+
# @example
|
|
76
|
+
# Parallelizer.new(%w[a b c]).call { |s| s.upcase }
|
|
77
|
+
# # => ["A", "B", "C"]
|
|
78
|
+
#
|
|
79
|
+
# @rbs [T, R] () { (T) -> R } -> Array[R]
|
|
80
|
+
def call(&block)
|
|
81
|
+
results = Array.new(items.size)
|
|
82
|
+
queue = Queue.new
|
|
83
|
+
|
|
84
|
+
items.each_with_index { |item, i| queue << [item, i] }
|
|
85
|
+
pool_size.times { queue << nil }
|
|
86
|
+
|
|
87
|
+
Array.new(pool_size) do
|
|
88
|
+
Thread.new do
|
|
89
|
+
while (entry = queue.pop)
|
|
90
|
+
item, index = entry
|
|
91
|
+
results[index] = block.call(item) # rubocop:disable Performance/RedundantBlockCall
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end.each(&:join)
|
|
95
|
+
|
|
96
|
+
results
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
end
|
|
100
|
+
end
|
data/lib/cmdx/pipeline.rb
CHANGED
|
@@ -6,6 +6,14 @@ module CMDx
|
|
|
6
6
|
# and handling breakpoints that can interrupt execution at specific task statuses.
|
|
7
7
|
class Pipeline
|
|
8
8
|
|
|
9
|
+
# @rbs SEQUENTIAL_REGEXP: Regexp
|
|
10
|
+
SEQUENTIAL_REGEXP = /\Asequential\z/
|
|
11
|
+
private_constant :SEQUENTIAL_REGEXP
|
|
12
|
+
|
|
13
|
+
# @rbs PARALLEL_REGEXP: Regexp
|
|
14
|
+
PARALLEL_REGEXP = /\Aparallel\z/
|
|
15
|
+
private_constant :PARALLEL_REGEXP
|
|
16
|
+
|
|
9
17
|
# Returns the workflow being executed by this pipeline.
|
|
10
18
|
#
|
|
11
19
|
# @return [Workflow] The workflow instance
|
|
@@ -54,13 +62,20 @@ module CMDx
|
|
|
54
62
|
#
|
|
55
63
|
# @rbs () -> void
|
|
56
64
|
def execute
|
|
65
|
+
default_breakpoints = Utils::Normalize.statuses(
|
|
66
|
+
workflow.class.settings.breakpoints ||
|
|
67
|
+
workflow.class.settings.workflow_breakpoints
|
|
68
|
+
)
|
|
69
|
+
|
|
57
70
|
workflow.class.pipeline.each do |group|
|
|
58
71
|
next unless Utils::Condition.evaluate(workflow, group.options)
|
|
59
72
|
|
|
60
|
-
breakpoints =
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
73
|
+
breakpoints =
|
|
74
|
+
if group.options.key?(:breakpoints)
|
|
75
|
+
Utils::Normalize.statuses(group.options[:breakpoints])
|
|
76
|
+
else
|
|
77
|
+
default_breakpoints
|
|
78
|
+
end
|
|
64
79
|
|
|
65
80
|
execute_group_tasks(group, breakpoints)
|
|
66
81
|
end
|
|
@@ -82,8 +97,8 @@ module CMDx
|
|
|
82
97
|
# @rbs (untyped group, Array[String] breakpoints) -> void
|
|
83
98
|
def execute_group_tasks(group, breakpoints)
|
|
84
99
|
case strategy = group.options[:strategy]
|
|
85
|
-
when NilClass,
|
|
86
|
-
when
|
|
100
|
+
when NilClass, SEQUENTIAL_REGEXP then execute_tasks_in_sequence(group, breakpoints)
|
|
101
|
+
when PARALLEL_REGEXP then execute_tasks_in_parallel(group, breakpoints)
|
|
87
102
|
else raise "unknown execution strategy #{strategy.inspect}"
|
|
88
103
|
end
|
|
89
104
|
end
|
|
@@ -111,39 +126,43 @@ module CMDx
|
|
|
111
126
|
end
|
|
112
127
|
end
|
|
113
128
|
|
|
114
|
-
#
|
|
129
|
+
# Each task receives a snapshot of the workflow context to prevent
|
|
130
|
+
# unsynchronized concurrent writes to a shared Hash. Snapshots are
|
|
131
|
+
# merged back into the workflow context after all tasks complete.
|
|
115
132
|
#
|
|
116
133
|
# @param group [CMDx::Group] The task group to execute in parallel
|
|
117
|
-
# @param breakpoints [Array<
|
|
118
|
-
# @option group.options [Integer] :
|
|
119
|
-
# @option group.options [Integer] :in_processes Number of processes to use
|
|
134
|
+
# @param breakpoints [Array<String>] Status values that trigger execution breaks
|
|
135
|
+
# @option group.options [Integer] :pool_size Number of concurrent threads (defaults to task count)
|
|
120
136
|
#
|
|
121
137
|
# @return [void]
|
|
122
138
|
#
|
|
123
|
-
# @raise [
|
|
139
|
+
# @raise [Fault] When a task result status matches a breakpoint
|
|
124
140
|
#
|
|
125
141
|
# @example
|
|
126
142
|
# execute_tasks_in_parallel(group, ["failed"])
|
|
127
143
|
#
|
|
128
144
|
# @rbs (untyped group, Array[String] breakpoints) -> void
|
|
129
145
|
def execute_tasks_in_parallel(group, breakpoints)
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
throwable_result = nil
|
|
146
|
+
contexts = group.tasks.map { Context.new(workflow.context.to_h) }
|
|
147
|
+
ctx_pairs = group.tasks.zip(contexts)
|
|
148
|
+
pool_size = group.options.fetch(:pool_size, ctx_pairs.size)
|
|
134
149
|
|
|
135
|
-
|
|
150
|
+
results = Parallelizer.call(ctx_pairs, pool_size:) do |task, context|
|
|
136
151
|
Chain.current = workflow.chain
|
|
137
|
-
|
|
138
|
-
task_result = task.execute(workflow.context)
|
|
139
|
-
next unless breakpoints.include?(task_result.status)
|
|
140
|
-
|
|
141
|
-
raise Parallel::Break, throwable_result = task_result
|
|
152
|
+
task.execute(context)
|
|
142
153
|
end
|
|
143
154
|
|
|
144
|
-
|
|
155
|
+
contexts.each { |ctx| workflow.context.merge!(ctx) }
|
|
156
|
+
|
|
157
|
+
faulted = results.select { |r| breakpoints.include?(r.status) }
|
|
158
|
+
return if faulted.empty?
|
|
145
159
|
|
|
146
|
-
workflow.
|
|
160
|
+
workflow.public_send(
|
|
161
|
+
:"#{faulted.last.status}!",
|
|
162
|
+
Locale.t("cmdx.reasons.unspecified"),
|
|
163
|
+
source: :parallel,
|
|
164
|
+
faults: faulted.map(&:to_h)
|
|
165
|
+
)
|
|
147
166
|
end
|
|
148
167
|
|
|
149
168
|
end
|
data/lib/cmdx/railtie.rb
CHANGED
|
@@ -24,7 +24,7 @@ module CMDx
|
|
|
24
24
|
#
|
|
25
25
|
# @rbs (untyped app) -> void
|
|
26
26
|
initializer("cmdx.configure_locales") do |app|
|
|
27
|
-
|
|
27
|
+
Utils::Wrap.array(app.config.i18n.available_locales).each do |locale|
|
|
28
28
|
path = CMDx.gem_path.join("lib/locales/#{locale}.yml")
|
|
29
29
|
next unless File.file?(path)
|
|
30
30
|
|
data/lib/cmdx/result.rb
CHANGED
|
@@ -28,13 +28,17 @@ module CMDx
|
|
|
28
28
|
|
|
29
29
|
# @rbs STRIP_FAILURE: Proc
|
|
30
30
|
STRIP_FAILURE = proc do |hash, result, key|
|
|
31
|
-
unless result.
|
|
31
|
+
unless result.public_send(:"#{key}?")
|
|
32
32
|
# Strip caused/threw failures since its the same info as the log line
|
|
33
|
-
hash[key] = result.
|
|
33
|
+
hash[key] = result.public_send(key).to_h.except(:caused_failure, :threw_failure)
|
|
34
34
|
end
|
|
35
35
|
end.freeze
|
|
36
36
|
private_constant :STRIP_FAILURE
|
|
37
37
|
|
|
38
|
+
# @rbs FAILURE_KEY_REGEX: Regexp
|
|
39
|
+
FAILURE_KEY_REGEX = /_failure\z/
|
|
40
|
+
private_constant :FAILURE_KEY_REGEX
|
|
41
|
+
|
|
38
42
|
# Returns the task instance associated with this result.
|
|
39
43
|
#
|
|
40
44
|
# @return [CMDx::Task] The task instance
|
|
@@ -105,6 +109,18 @@ module CMDx
|
|
|
105
109
|
# @rbs @retries: Integer
|
|
106
110
|
attr_accessor :retries
|
|
107
111
|
|
|
112
|
+
# Returns whether this result is strict.
|
|
113
|
+
# When false, {CMDx::Executor#halt_execution?} returns false
|
|
114
|
+
# regardless of the task's breakpoint settings.
|
|
115
|
+
#
|
|
116
|
+
# @return [Boolean] Whether the result is strict
|
|
117
|
+
#
|
|
118
|
+
# @example
|
|
119
|
+
# result.strict? # => true
|
|
120
|
+
#
|
|
121
|
+
# @rbs @strict: bool
|
|
122
|
+
attr_reader :strict
|
|
123
|
+
|
|
108
124
|
# Returns whether the result has been rolled back.
|
|
109
125
|
#
|
|
110
126
|
# @return [Boolean] Whether the result has been rolled back
|
|
@@ -139,6 +155,7 @@ module CMDx
|
|
|
139
155
|
@reason = nil
|
|
140
156
|
@cause = nil
|
|
141
157
|
@retries = 0
|
|
158
|
+
@strict = true
|
|
142
159
|
@rolled_back = false
|
|
143
160
|
end
|
|
144
161
|
|
|
@@ -261,13 +278,40 @@ module CMDx
|
|
|
261
278
|
def on(*states_or_statuses, &)
|
|
262
279
|
raise ArgumentError, "block required" unless block_given?
|
|
263
280
|
|
|
264
|
-
yield(self) if states_or_statuses.any? { |s|
|
|
281
|
+
yield(self) if states_or_statuses.any? { |s| public_send(:"#{s}?") }
|
|
265
282
|
self
|
|
266
283
|
end
|
|
267
284
|
|
|
285
|
+
# Sets a reason and optional metadata on a successful result without
|
|
286
|
+
# changing its state or status. Useful for annotating why a task succeeded.
|
|
287
|
+
# When halt is true, uses throw/catch to exit the work method early.
|
|
288
|
+
#
|
|
289
|
+
# @param reason [String, nil] Reason or note for the success
|
|
290
|
+
# @param halt [Boolean] Whether to halt execution after success
|
|
291
|
+
# @param metadata [Hash] Additional metadata about the success
|
|
292
|
+
# @option metadata [Object] :* Any key-value pairs for additional metadata
|
|
293
|
+
#
|
|
294
|
+
# @raise [RuntimeError] When status is not success
|
|
295
|
+
#
|
|
296
|
+
# @example
|
|
297
|
+
# result.success!("Created 42 records")
|
|
298
|
+
# result.success!("Imported", halt: false, rows: 100)
|
|
299
|
+
#
|
|
300
|
+
# @rbs (?String? reason, halt: bool, **untyped metadata) -> void
|
|
301
|
+
def success!(reason = nil, halt: true, **metadata)
|
|
302
|
+
raise "can only be used while #{SUCCESS}" unless success?
|
|
303
|
+
|
|
304
|
+
@reason = reason
|
|
305
|
+
@metadata = metadata
|
|
306
|
+
|
|
307
|
+
throw(:cmdx_halt) if halt
|
|
308
|
+
end
|
|
309
|
+
|
|
268
310
|
# @param reason [String, nil] Reason for skipping the task
|
|
269
311
|
# @param halt [Boolean] Whether to halt execution after skipping
|
|
270
312
|
# @param cause [Exception, nil] Exception that caused the skip
|
|
313
|
+
# @param strict [Boolean] Whether this skip is strict (default: true).
|
|
314
|
+
# When false, {CMDx::Executor#halt_execution?} returns false regardless of task settings.
|
|
271
315
|
# @param metadata [Hash] Additional metadata about the skip
|
|
272
316
|
# @option metadata [Object] :* Any key-value pairs for additional metadata
|
|
273
317
|
#
|
|
@@ -276,17 +320,19 @@ module CMDx
|
|
|
276
320
|
# @example
|
|
277
321
|
# result.skip!("Dependencies not met", cause: dependency_error)
|
|
278
322
|
# result.skip!("Already processed", halt: false)
|
|
323
|
+
# result.skip!("Optional step", strict: false)
|
|
279
324
|
#
|
|
280
|
-
# @rbs (?String? reason, halt: bool, cause: Exception?, **untyped metadata) -> void
|
|
281
|
-
def skip!(reason = nil, halt: true, cause: nil, **metadata)
|
|
325
|
+
# @rbs (?String? reason, halt: bool, cause: Exception?, strict: bool, **untyped metadata) -> void
|
|
326
|
+
def skip!(reason = nil, halt: true, cause: nil, strict: true, **metadata)
|
|
282
327
|
return if skipped?
|
|
283
328
|
|
|
284
329
|
raise "can only transition to #{SKIPPED} from #{SUCCESS}" unless success?
|
|
285
330
|
|
|
286
331
|
@state = INTERRUPTED
|
|
287
332
|
@status = SKIPPED
|
|
288
|
-
@reason = reason || Locale.t("cmdx.
|
|
333
|
+
@reason = reason || Locale.t("cmdx.reasons.unspecified")
|
|
289
334
|
@cause = cause
|
|
335
|
+
@strict = strict
|
|
290
336
|
@metadata = metadata
|
|
291
337
|
|
|
292
338
|
halt! if halt
|
|
@@ -295,6 +341,8 @@ module CMDx
|
|
|
295
341
|
# @param reason [String, nil] Reason for task failure
|
|
296
342
|
# @param halt [Boolean] Whether to halt execution after failure
|
|
297
343
|
# @param cause [Exception, nil] Exception that caused the failure
|
|
344
|
+
# @param strict [Boolean] Whether this failure is strict (default: true).
|
|
345
|
+
# When false, {CMDx::Executor#halt_execution?} returns false regardless of task settings.
|
|
298
346
|
# @param metadata [Hash] Additional metadata about the failure
|
|
299
347
|
# @option metadata [Object] :* Any key-value pairs for additional metadata
|
|
300
348
|
#
|
|
@@ -303,17 +351,19 @@ module CMDx
|
|
|
303
351
|
# @example
|
|
304
352
|
# result.fail!("Validation failed", cause: validation_error)
|
|
305
353
|
# result.fail!("Network timeout", halt: false, timeout: 30)
|
|
354
|
+
# result.fail!("Soft failure", strict: false)
|
|
306
355
|
#
|
|
307
|
-
# @rbs (?String? reason, halt: bool, cause: Exception?, **untyped metadata) -> void
|
|
308
|
-
def fail!(reason = nil, halt: true, cause: nil, **metadata)
|
|
356
|
+
# @rbs (?String? reason, halt: bool, cause: Exception?, strict: bool, **untyped metadata) -> void
|
|
357
|
+
def fail!(reason = nil, halt: true, cause: nil, strict: true, **metadata)
|
|
309
358
|
return if failed?
|
|
310
359
|
|
|
311
360
|
raise "can only transition to #{FAILED} from #{SUCCESS}" unless success?
|
|
312
361
|
|
|
313
362
|
@state = INTERRUPTED
|
|
314
363
|
@status = FAILED
|
|
315
|
-
@reason = reason || Locale.t("cmdx.
|
|
364
|
+
@reason = reason || Locale.t("cmdx.reasons.unspecified")
|
|
316
365
|
@cause = cause
|
|
366
|
+
@strict = strict
|
|
317
367
|
@metadata = metadata
|
|
318
368
|
|
|
319
369
|
halt! if halt
|
|
@@ -338,7 +388,7 @@ module CMDx
|
|
|
338
388
|
unless frames.empty?
|
|
339
389
|
frames = frames.map(&:to_s)
|
|
340
390
|
|
|
341
|
-
if (cleaner = task.class.settings
|
|
391
|
+
if (cleaner = task.class.settings.backtrace_cleaner)
|
|
342
392
|
cleaner.call(frames)
|
|
343
393
|
end
|
|
344
394
|
|
|
@@ -383,7 +433,7 @@ module CMDx
|
|
|
383
433
|
def caused_failure
|
|
384
434
|
return unless failed?
|
|
385
435
|
|
|
386
|
-
chain.results.
|
|
436
|
+
chain.results.reverse_each.find(&:failed?)
|
|
387
437
|
end
|
|
388
438
|
|
|
389
439
|
# @return [Boolean] Whether this result caused the failure
|
|
@@ -411,8 +461,17 @@ module CMDx
|
|
|
411
461
|
return unless failed?
|
|
412
462
|
|
|
413
463
|
current = index
|
|
414
|
-
|
|
415
|
-
|
|
464
|
+
last_failed = nil
|
|
465
|
+
|
|
466
|
+
chain.results.each do |r|
|
|
467
|
+
next unless r.failed?
|
|
468
|
+
|
|
469
|
+
return r if r.index > current
|
|
470
|
+
|
|
471
|
+
last_failed = r
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
last_failed
|
|
416
475
|
end
|
|
417
476
|
|
|
418
477
|
# @return [Boolean] Whether this result threw the failure
|
|
@@ -451,6 +510,16 @@ module CMDx
|
|
|
451
510
|
retries.positive?
|
|
452
511
|
end
|
|
453
512
|
|
|
513
|
+
# @return [Boolean] Whether the result is strict
|
|
514
|
+
#
|
|
515
|
+
# @example
|
|
516
|
+
# result.strict? # => true
|
|
517
|
+
#
|
|
518
|
+
# @rbs () -> bool
|
|
519
|
+
def strict?
|
|
520
|
+
!!@strict
|
|
521
|
+
end
|
|
522
|
+
|
|
454
523
|
# @return [Boolean] Whether the result has been rolled back
|
|
455
524
|
#
|
|
456
525
|
# @example
|
|
@@ -469,7 +538,7 @@ module CMDx
|
|
|
469
538
|
#
|
|
470
539
|
# @rbs () -> Integer
|
|
471
540
|
def index
|
|
472
|
-
chain.index(self)
|
|
541
|
+
@chain_index || chain.index(self)
|
|
473
542
|
end
|
|
474
543
|
|
|
475
544
|
# @return [String] The outcome of the task execution
|
|
@@ -486,7 +555,7 @@ module CMDx
|
|
|
486
555
|
#
|
|
487
556
|
# @example
|
|
488
557
|
# result.to_h
|
|
489
|
-
# # => {state: "complete", status: "success", outcome: "success", metadata: {}}
|
|
558
|
+
# # => {state: "complete", status: "success", outcome: "success", reason: "Unspecified", metadata: {}}
|
|
490
559
|
#
|
|
491
560
|
# @rbs () -> Hash[Symbol, untyped]
|
|
492
561
|
def to_h
|
|
@@ -494,10 +563,10 @@ module CMDx
|
|
|
494
563
|
state:,
|
|
495
564
|
status:,
|
|
496
565
|
outcome:,
|
|
566
|
+
reason:,
|
|
497
567
|
metadata:
|
|
498
568
|
).tap do |hash|
|
|
499
569
|
if interrupted?
|
|
500
|
-
hash[:reason] = reason
|
|
501
570
|
hash[:cause] = cause
|
|
502
571
|
hash[:rolled_back] = rolled_back?
|
|
503
572
|
end
|
|
@@ -513,13 +582,16 @@ module CMDx
|
|
|
513
582
|
#
|
|
514
583
|
# @example
|
|
515
584
|
# result.to_s # => "task_id=my_task state=complete status=success"
|
|
585
|
+
# @example With failure
|
|
586
|
+
# result.to_s # => "task_id=my_task state=complete status=failed threw_failure=<[1] MyTask: my_task>"
|
|
516
587
|
#
|
|
517
588
|
# @rbs () -> String
|
|
518
589
|
def to_s
|
|
519
590
|
Utils::Format.to_str(to_h) do |key, value|
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
else
|
|
591
|
+
if FAILURE_KEY_REGEX.match?(key)
|
|
592
|
+
"#{key}=<[#{value[:index]}] #{value[:class]}: #{value[:id]}>"
|
|
593
|
+
else
|
|
594
|
+
"#{key}=#{value.inspect}"
|
|
523
595
|
end
|
|
524
596
|
end
|
|
525
597
|
end
|
data/lib/cmdx/retry.rb
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CMDx
|
|
4
|
+
# Manages retry logic and state for task execution.
|
|
5
|
+
#
|
|
6
|
+
# The Retry class tracks retry availability, attempt counts, and
|
|
7
|
+
# remaining retries for a given task. It also resolves exception
|
|
8
|
+
# matching and computes wait times using configurable jitter strategies.
|
|
9
|
+
class Retry
|
|
10
|
+
|
|
11
|
+
# Returns the task instance associated with this retry.
|
|
12
|
+
#
|
|
13
|
+
# @return [Task] the task being retried
|
|
14
|
+
#
|
|
15
|
+
# @example
|
|
16
|
+
# retry_instance.task # => #<CreateUser ...>
|
|
17
|
+
#
|
|
18
|
+
# @rbs @task: Task
|
|
19
|
+
attr_reader :task
|
|
20
|
+
|
|
21
|
+
# Creates a new Retry instance for the given task.
|
|
22
|
+
#
|
|
23
|
+
# @param task [Task] the task to manage retries for
|
|
24
|
+
#
|
|
25
|
+
# @return [Retry] a new Retry instance
|
|
26
|
+
#
|
|
27
|
+
# @example
|
|
28
|
+
# retry_instance = Retry.new(task)
|
|
29
|
+
#
|
|
30
|
+
# @rbs (Task task) -> void
|
|
31
|
+
def initialize(task)
|
|
32
|
+
@task = task
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Returns the total number of retries configured for the task.
|
|
36
|
+
#
|
|
37
|
+
# @return [Integer] the configured retry count
|
|
38
|
+
#
|
|
39
|
+
# @example
|
|
40
|
+
# retry_instance.available # => 3
|
|
41
|
+
#
|
|
42
|
+
# @rbs () -> Integer
|
|
43
|
+
def available
|
|
44
|
+
Integer(task.class.settings.retries || 0)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Checks if the task has any retries configured.
|
|
48
|
+
#
|
|
49
|
+
# @return [Boolean] true if retries are configured
|
|
50
|
+
#
|
|
51
|
+
# @example
|
|
52
|
+
# retry_instance.available? # => true
|
|
53
|
+
#
|
|
54
|
+
# @rbs () -> bool
|
|
55
|
+
def available?
|
|
56
|
+
available.positive?
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Returns the number of retry attempts already made.
|
|
60
|
+
#
|
|
61
|
+
# @return [Integer] the current retry attempt count
|
|
62
|
+
#
|
|
63
|
+
# @example
|
|
64
|
+
# retry_instance.attempts # => 1
|
|
65
|
+
#
|
|
66
|
+
# @rbs () -> Integer
|
|
67
|
+
def attempts
|
|
68
|
+
Integer(task.result.retries || 0)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Checks if the task has been retried at least once.
|
|
72
|
+
#
|
|
73
|
+
# @return [Boolean] true if at least one retry has occurred
|
|
74
|
+
#
|
|
75
|
+
# @example
|
|
76
|
+
# retry_instance.retried? # => true
|
|
77
|
+
#
|
|
78
|
+
# @rbs () -> bool
|
|
79
|
+
def retried?
|
|
80
|
+
attempts.positive?
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Returns the number of retries still available.
|
|
84
|
+
#
|
|
85
|
+
# @return [Integer] the remaining retry count
|
|
86
|
+
#
|
|
87
|
+
# @example
|
|
88
|
+
# retry_instance.remaining # => 2
|
|
89
|
+
#
|
|
90
|
+
# @rbs () -> Integer
|
|
91
|
+
def remaining
|
|
92
|
+
available - attempts
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Checks if there are retries still available.
|
|
96
|
+
#
|
|
97
|
+
# @return [Boolean] true if remaining retries exist
|
|
98
|
+
#
|
|
99
|
+
# @example
|
|
100
|
+
# retry_instance.remaining? # => true
|
|
101
|
+
#
|
|
102
|
+
# @rbs () -> bool
|
|
103
|
+
def remaining?
|
|
104
|
+
remaining.positive?
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Returns the list of exception classes eligible for retry.
|
|
108
|
+
#
|
|
109
|
+
# @return [Array<Class>] exception classes that trigger a retry
|
|
110
|
+
#
|
|
111
|
+
# @example
|
|
112
|
+
# retry_instance.exceptions # => [StandardError, CMDx::TimeoutError]
|
|
113
|
+
#
|
|
114
|
+
# @rbs () -> Array[Class]
|
|
115
|
+
def exceptions
|
|
116
|
+
@exceptions ||= Utils::Wrap.array(
|
|
117
|
+
task.class.settings.retry_on ||
|
|
118
|
+
[StandardError, CMDx::TimeoutError]
|
|
119
|
+
)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Checks if the given exception matches any configured retry exception.
|
|
123
|
+
#
|
|
124
|
+
# @param exception [Exception] the exception to check
|
|
125
|
+
#
|
|
126
|
+
# @return [Boolean] true if the exception qualifies for retry
|
|
127
|
+
#
|
|
128
|
+
# @example
|
|
129
|
+
# retry_instance.exception?(RuntimeError.new("fail")) # => true
|
|
130
|
+
#
|
|
131
|
+
# @rbs (Exception exception) -> bool
|
|
132
|
+
def exception?(exception)
|
|
133
|
+
exceptions.any? { |e| exception.class <= e }
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Computes the wait time before the next retry attempt.
|
|
137
|
+
#
|
|
138
|
+
# Supports multiple jitter strategies: a Symbol calls a task method,
|
|
139
|
+
# a Proc is evaluated in the task instance context, a callable object
|
|
140
|
+
# receives the task and attempts, and a Numeric is multiplied by the
|
|
141
|
+
# attempt count.
|
|
142
|
+
#
|
|
143
|
+
# @return [Float] the wait duration in seconds
|
|
144
|
+
#
|
|
145
|
+
# @example With numeric jitter (0.5 * attempts)
|
|
146
|
+
# retry_instance.wait # => 1.0
|
|
147
|
+
# @example With symbol jitter referencing a task method
|
|
148
|
+
# retry_instance.wait # => 2.5
|
|
149
|
+
#
|
|
150
|
+
# @rbs () -> Float
|
|
151
|
+
def wait
|
|
152
|
+
jitter = task.class.settings.retry_jitter
|
|
153
|
+
|
|
154
|
+
if jitter.is_a?(Symbol)
|
|
155
|
+
task.send(jitter, attempts)
|
|
156
|
+
elsif jitter.is_a?(Proc)
|
|
157
|
+
task.instance_exec(attempts, &jitter)
|
|
158
|
+
elsif jitter.respond_to?(:call)
|
|
159
|
+
jitter.call(task, attempts)
|
|
160
|
+
else
|
|
161
|
+
jitter.to_f * attempts
|
|
162
|
+
end.to_f
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
end
|
|
166
|
+
end
|