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.
Files changed (145) hide show
  1. checksums.yaml +4 -4
  2. data/.DS_Store +0 -0
  3. data/CHANGELOG.md +82 -16
  4. data/README.md +1 -1
  5. data/lib/cmdx/attribute.rb +82 -19
  6. data/lib/cmdx/attribute_registry.rb +79 -8
  7. data/lib/cmdx/attribute_value.rb +2 -2
  8. data/lib/cmdx/callback_registry.rb +60 -26
  9. data/lib/cmdx/chain.rb +34 -5
  10. data/lib/cmdx/coercion_registry.rb +42 -20
  11. data/lib/cmdx/coercions/array.rb +2 -2
  12. data/lib/cmdx/coercions/big_decimal.rb +1 -1
  13. data/lib/cmdx/coercions/boolean.rb +2 -2
  14. data/lib/cmdx/coercions/complex.rb +1 -1
  15. data/lib/cmdx/coercions/date.rb +1 -1
  16. data/lib/cmdx/coercions/date_time.rb +1 -1
  17. data/lib/cmdx/coercions/float.rb +1 -1
  18. data/lib/cmdx/coercions/hash.rb +1 -1
  19. data/lib/cmdx/coercions/integer.rb +1 -1
  20. data/lib/cmdx/coercions/rational.rb +1 -1
  21. data/lib/cmdx/coercions/string.rb +1 -1
  22. data/lib/cmdx/coercions/symbol.rb +1 -1
  23. data/lib/cmdx/coercions/time.rb +1 -1
  24. data/lib/cmdx/configuration.rb +38 -0
  25. data/lib/cmdx/context.rb +11 -8
  26. data/lib/cmdx/deprecator.rb +27 -14
  27. data/lib/cmdx/errors.rb +3 -4
  28. data/lib/cmdx/exception.rb +7 -0
  29. data/lib/cmdx/executor.rb +80 -53
  30. data/lib/cmdx/identifier.rb +4 -6
  31. data/lib/cmdx/locale.rb +32 -9
  32. data/lib/cmdx/middleware_registry.rb +43 -23
  33. data/lib/cmdx/middlewares/correlate.rb +4 -2
  34. data/lib/cmdx/middlewares/runtime.rb +18 -3
  35. data/lib/cmdx/middlewares/timeout.rb +11 -10
  36. data/lib/cmdx/parallelizer.rb +100 -0
  37. data/lib/cmdx/pipeline.rb +42 -23
  38. data/lib/cmdx/railtie.rb +1 -1
  39. data/lib/cmdx/result.rb +91 -19
  40. data/lib/cmdx/retry.rb +166 -0
  41. data/lib/cmdx/settings.rb +226 -0
  42. data/lib/cmdx/task.rb +62 -65
  43. data/lib/cmdx/utils/format.rb +17 -1
  44. data/lib/cmdx/utils/normalize.rb +52 -0
  45. data/lib/cmdx/utils/wrap.rb +38 -0
  46. data/lib/cmdx/validator_registry.rb +44 -19
  47. data/lib/cmdx/validators/absence.rb +1 -1
  48. data/lib/cmdx/validators/exclusion.rb +2 -2
  49. data/lib/cmdx/validators/format.rb +1 -1
  50. data/lib/cmdx/validators/inclusion.rb +2 -2
  51. data/lib/cmdx/validators/length.rb +1 -1
  52. data/lib/cmdx/validators/numeric.rb +1 -1
  53. data/lib/cmdx/validators/presence.rb +1 -1
  54. data/lib/cmdx/version.rb +1 -1
  55. data/lib/cmdx/workflow.rb +17 -0
  56. data/lib/cmdx.rb +12 -0
  57. data/lib/generators/cmdx/templates/install.rb +20 -5
  58. data/lib/locales/af.yml +2 -0
  59. data/lib/locales/ar.yml +2 -0
  60. data/lib/locales/az.yml +2 -0
  61. data/lib/locales/be.yml +2 -0
  62. data/lib/locales/bg.yml +2 -0
  63. data/lib/locales/bn.yml +2 -0
  64. data/lib/locales/bs.yml +2 -0
  65. data/lib/locales/ca.yml +2 -0
  66. data/lib/locales/cnr.yml +2 -0
  67. data/lib/locales/cs.yml +2 -0
  68. data/lib/locales/cy.yml +2 -0
  69. data/lib/locales/da.yml +2 -0
  70. data/lib/locales/de.yml +2 -0
  71. data/lib/locales/dz.yml +2 -0
  72. data/lib/locales/el.yml +2 -0
  73. data/lib/locales/en.yml +2 -0
  74. data/lib/locales/eo.yml +2 -0
  75. data/lib/locales/es.yml +2 -0
  76. data/lib/locales/et.yml +2 -0
  77. data/lib/locales/eu.yml +2 -0
  78. data/lib/locales/fa.yml +2 -0
  79. data/lib/locales/fi.yml +2 -0
  80. data/lib/locales/fr.yml +2 -0
  81. data/lib/locales/fy.yml +2 -0
  82. data/lib/locales/gd.yml +2 -0
  83. data/lib/locales/gl.yml +2 -0
  84. data/lib/locales/he.yml +2 -0
  85. data/lib/locales/hi.yml +2 -0
  86. data/lib/locales/hr.yml +2 -0
  87. data/lib/locales/hu.yml +2 -0
  88. data/lib/locales/hy.yml +2 -0
  89. data/lib/locales/id.yml +2 -0
  90. data/lib/locales/is.yml +2 -0
  91. data/lib/locales/it.yml +2 -0
  92. data/lib/locales/ja.yml +2 -0
  93. data/lib/locales/ka.yml +2 -0
  94. data/lib/locales/kk.yml +2 -0
  95. data/lib/locales/km.yml +2 -0
  96. data/lib/locales/kn.yml +2 -0
  97. data/lib/locales/ko.yml +2 -0
  98. data/lib/locales/lb.yml +2 -0
  99. data/lib/locales/lo.yml +2 -0
  100. data/lib/locales/lt.yml +2 -0
  101. data/lib/locales/lv.yml +2 -0
  102. data/lib/locales/mg.yml +2 -0
  103. data/lib/locales/mk.yml +2 -0
  104. data/lib/locales/ml.yml +2 -0
  105. data/lib/locales/mn.yml +2 -0
  106. data/lib/locales/mr-IN.yml +2 -0
  107. data/lib/locales/ms.yml +2 -0
  108. data/lib/locales/nb.yml +2 -0
  109. data/lib/locales/ne.yml +2 -0
  110. data/lib/locales/nl.yml +2 -0
  111. data/lib/locales/nn.yml +2 -0
  112. data/lib/locales/oc.yml +2 -0
  113. data/lib/locales/or.yml +2 -0
  114. data/lib/locales/pa.yml +2 -0
  115. data/lib/locales/pl.yml +2 -0
  116. data/lib/locales/pt.yml +2 -0
  117. data/lib/locales/rm.yml +2 -0
  118. data/lib/locales/ro.yml +2 -0
  119. data/lib/locales/ru.yml +2 -0
  120. data/lib/locales/sc.yml +2 -0
  121. data/lib/locales/sk.yml +2 -0
  122. data/lib/locales/sl.yml +2 -0
  123. data/lib/locales/sq.yml +2 -0
  124. data/lib/locales/sr.yml +2 -0
  125. data/lib/locales/st.yml +2 -0
  126. data/lib/locales/sv.yml +2 -0
  127. data/lib/locales/sw.yml +2 -0
  128. data/lib/locales/ta.yml +2 -0
  129. data/lib/locales/te.yml +2 -0
  130. data/lib/locales/th.yml +2 -0
  131. data/lib/locales/tl.yml +2 -0
  132. data/lib/locales/tr.yml +2 -0
  133. data/lib/locales/tt.yml +2 -0
  134. data/lib/locales/ug.yml +2 -0
  135. data/lib/locales/uk.yml +2 -0
  136. data/lib/locales/ur.yml +2 -0
  137. data/lib/locales/uz.yml +2 -0
  138. data/lib/locales/vi.yml +2 -0
  139. data/lib/locales/wo.yml +2 -0
  140. data/lib/locales/zh-CN.yml +2 -0
  141. data/lib/locales/zh-HK.yml +2 -0
  142. data/lib/locales/zh-TW.yml +2 -0
  143. data/lib/locales/zh-YUE.yml +2 -0
  144. data/mkdocs.yml +5 -1
  145. 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 = group.options[:breakpoints] ||
61
- workflow.class.settings[:breakpoints] ||
62
- workflow.class.settings[:workflow_breakpoints]
63
- breakpoints = Array(breakpoints).map(&:to_s).uniq
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, /sequential/ then execute_tasks_in_sequence(group, breakpoints)
86
- when /parallel/ then execute_tasks_in_parallel(group, breakpoints)
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
- # Executes tasks in parallel using the parallel gem.
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<Symbol>] Status values that trigger execution breaks
118
- # @option group.options [Integer] :in_threads Number of threads to use
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 [HaltError] When a task result status matches a breakpoint
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
- raise "install the `parallel` gem to use this feature" unless defined?(Parallel)
131
-
132
- parallel_options = group.options.slice(:in_threads, :in_processes)
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
- Parallel.each(group.tasks, **parallel_options) do |task|
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
- return if throwable_result.nil?
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.throw!(throwable_result)
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
- Array(app.config.i18n.available_locales).each do |locale|
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.send(:"#{key}?")
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.send(key).to_h.except(:caused_failure, :threw_failure)
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| send(:"#{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.faults.unspecified")
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.faults.unspecified")
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[:backtrace_cleaner])
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.reverse.find(&:failed?)
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
- results = chain.results.select(&:failed?)
415
- results.find { |r| r.index > current } || results.last
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
- case key
521
- when /failure/ then "#{key}=<[#{value[:index]}] #{value[:class]}: #{value[:id]}>"
522
- else "#{key}=#{value.inspect}"
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