cmdx 1.11.0 → 1.13.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 (44) hide show
  1. checksums.yaml +4 -4
  2. data/.cursor/rules/cursor-instructions.mdc +8 -0
  3. data/.yard-lint.yml +174 -0
  4. data/CHANGELOG.md +24 -0
  5. data/docs/attributes/definitions.md +5 -1
  6. data/docs/attributes/validations.md +47 -6
  7. data/docs/basics/execution.md +56 -0
  8. data/docs/basics/setup.md +26 -3
  9. data/docs/deprecation.md +0 -2
  10. data/docs/getting_started.md +11 -0
  11. data/docs/logging.md +6 -10
  12. data/docs/outcomes/result.md +12 -8
  13. data/docs/outcomes/states.md +4 -4
  14. data/docs/outcomes/statuses.md +6 -6
  15. data/docs/tips_and_tricks.md +4 -0
  16. data/examples/active_record_database_transaction.md +27 -0
  17. data/examples/flipper_feature_flags.md +50 -0
  18. data/examples/redis_idempotency.md +71 -0
  19. data/examples/sentry_error_tracking.md +46 -0
  20. data/lib/cmdx/attribute.rb +6 -0
  21. data/lib/cmdx/callback_registry.rb +2 -1
  22. data/lib/cmdx/chain.rb +20 -7
  23. data/lib/cmdx/coercion_registry.rb +2 -1
  24. data/lib/cmdx/coercions/boolean.rb +3 -3
  25. data/lib/cmdx/coercions/complex.rb +1 -0
  26. data/lib/cmdx/coercions/string.rb +1 -0
  27. data/lib/cmdx/coercions/symbol.rb +1 -0
  28. data/lib/cmdx/configuration.rb +1 -3
  29. data/lib/cmdx/context.rb +5 -2
  30. data/lib/cmdx/executor.rb +29 -24
  31. data/lib/cmdx/middleware_registry.rb +1 -0
  32. data/lib/cmdx/result.rb +32 -89
  33. data/lib/cmdx/task.rb +10 -11
  34. data/lib/cmdx/utils/call.rb +3 -1
  35. data/lib/cmdx/utils/condition.rb +0 -3
  36. data/lib/cmdx/utils/format.rb +1 -1
  37. data/lib/cmdx/validators/exclusion.rb +2 -0
  38. data/lib/cmdx/validators/inclusion.rb +2 -0
  39. data/lib/cmdx/validators/length.rb +6 -0
  40. data/lib/cmdx/validators/numeric.rb +6 -0
  41. data/lib/cmdx/version.rb +1 -1
  42. data/lib/generators/cmdx/locale_generator.rb +6 -5
  43. data/mkdocs.yml +5 -1
  44. metadata +20 -1
@@ -0,0 +1,50 @@
1
+ # Flipper Feature Flags
2
+
3
+ Control task execution based on Flipper feature flags.
4
+
5
+ <https://github.com/flippercloud/flipper>
6
+
7
+ ### Setup
8
+
9
+ ```ruby
10
+ # lib/cmdx_flipper_middleware.rb
11
+ class CmdxFlipperMiddleware
12
+ def self.call(task, **options, &)
13
+ feature_name = options.fetch(:feature)
14
+ actor = options.fetch(:actor, -> { task.context[:user] })
15
+
16
+ # Resolve actor if it's a proc
17
+ actor = actor.call if actor.respond_to?(:call)
18
+
19
+ if Flipper.enabled?(feature_name, actor)
20
+ yield
21
+ else
22
+ # Option 1: Skip the task
23
+ task.skip!("Feature #{feature_name} is disabled")
24
+
25
+ # Option 2: Fail the task
26
+ # task.fail!("Feature #{feature_name} is disabled")
27
+ end
28
+ end
29
+ end
30
+ ```
31
+
32
+ ### Usage
33
+
34
+ ```ruby
35
+ class NewFeatureTask < CMDx::Task
36
+ # Execute only if :new_feature is enabled for the user in context
37
+ register :middleware, CmdxFlipperMiddleware,
38
+ feature: :new_feature
39
+
40
+ # Customize the actor resolution
41
+ register :middleware, CmdxFlipperMiddleware,
42
+ feature: :beta_access,
43
+ actor: -> { task.context[:company] }
44
+
45
+ def work
46
+ # ...
47
+ end
48
+ end
49
+ ```
50
+
@@ -0,0 +1,71 @@
1
+ # Redis Idempotency
2
+
3
+ Ensure tasks are executed exactly once using Redis to store execution state. This is critical for non-idempotent operations like charging a credit card or sending an email.
4
+
5
+ ### Setup
6
+
7
+ ```ruby
8
+ # lib/cmdx_redis_idempotency_middleware.rb
9
+ class CmdxRedisIdempotencyMiddleware
10
+ def self.call(task, **options, &block)
11
+ key = generate_key(task, options[:key])
12
+ ttl = options[:ttl] || 5.minutes.to_i
13
+
14
+ # Attempt to lock the key
15
+ if Redis.current.set(key, "processing", nx: true, ex: ttl)
16
+ begin
17
+ block.call.tap |result|
18
+ Redis.current.set(key, result.status, xx: true, ex: ttl)
19
+ end
20
+ rescue => e
21
+ Redis.current.del(key)
22
+ raise(e)
23
+ end
24
+ else
25
+ # Key exists, handle duplicate
26
+ status = Redis.current.get(key)
27
+
28
+ if status == "processing"
29
+ task.result.tap { |r| r.skip!("Duplicate request: currently processing", halt: true) }
30
+ else
31
+ task.result.tap { |r| r.skip!("Duplicate request: already processed (#{status})", halt: true) }
32
+ end
33
+ end
34
+ end
35
+
36
+ def self.generate_key(task, key_gen)
37
+ id = if key_gen.respond_to?(:call)
38
+ key_gen.call(task)
39
+ elsif key_gen.is_a?(Symbol)
40
+ task.send(key_gen)
41
+ else
42
+ task.context[:idempotency_key]
43
+ end
44
+
45
+ "cmdx:idempotency:#{task.class.name}:#{id}"
46
+ end
47
+ end
48
+ ```
49
+
50
+ ### Usage
51
+
52
+ ```ruby
53
+ class ChargeCustomer < CMDx::Task
54
+ # Use context[:payment_id] as the unique key
55
+ register :middleware, CmdxIdempotencyMiddleware,
56
+ key: ->(t) { t.context[:payment_id] }
57
+
58
+ def work
59
+ # Charge logic...
60
+ end
61
+ end
62
+
63
+ # First run: Executes
64
+ ChargeCustomer.call(payment_id: "123")
65
+ # => Success
66
+
67
+ # Second run: Skips
68
+ ChargeCustomer.call(payment_id: "123")
69
+ # => Skipped (reason: "Duplicate request: already processed (success)")
70
+ ```
71
+
@@ -0,0 +1,46 @@
1
+ # Sentry Error Tracking
2
+
3
+ Report unhandled exceptions and unexpected task failures to Sentry with detailed context.
4
+
5
+ <https://github.com/getsentry/sentry-ruby>
6
+
7
+ ### Setup
8
+
9
+ ```ruby
10
+ # lib/cmdx_sentry_middleware.rb
11
+ class CmdxSentryMiddleware
12
+ def self.call(task, **options, &)
13
+ Sentry.with_scope do |scope|
14
+ scope.set_tags(task: task.class.name)
15
+ scope.set_context(:user, Current.user.sentry_attributes)
16
+
17
+ yield.tap do |result|
18
+ # Optional: Report logical failures if needed
19
+ if Array(options[:report_on]).include?(result.status)
20
+ Sentry.capture_message("Task #{result.status}: #{result.reason}", level: :warning)
21
+ end
22
+ end
23
+ end
24
+ rescue => e
25
+ Sentry.capture_exception(e)
26
+ raise(e) # Re-raise to let the task handle the error or bubble up
27
+ end
28
+ end
29
+ ```
30
+
31
+ ### Usage
32
+
33
+ ```ruby
34
+ class ProcessPayment < CMDx::Task
35
+ # Report exceptions only
36
+ register :middleware, CmdxSentryMiddleware
37
+
38
+ # Report exceptions AND logical failures (result.failure?)
39
+ register :middleware, CmdxSentryMiddleware, report_on: %w[failed skipped]
40
+
41
+ def work
42
+ # ...
43
+ end
44
+ end
45
+ ```
46
+
@@ -112,6 +112,7 @@ module CMDx
112
112
  #
113
113
  # @param names [Array<Symbol, String>] The names of the attributes to create
114
114
  # @param options [Hash] Configuration options for the attributes
115
+ # @option options [Object] :* Any attribute configuration option
115
116
  #
116
117
  # @yield [self] Block to configure nested attributes
117
118
  #
@@ -137,6 +138,7 @@ module CMDx
137
138
  #
138
139
  # @param names [Array<Symbol, String>] The names of the attributes to create
139
140
  # @param options [Hash] Configuration options for the attributes
141
+ # @option options [Object] :* Any attribute configuration option
140
142
  #
141
143
  # @yield [self] Block to configure nested attributes
142
144
  #
@@ -154,6 +156,7 @@ module CMDx
154
156
  #
155
157
  # @param names [Array<Symbol, String>] The names of the attributes to create
156
158
  # @param options [Hash] Configuration options for the attributes
159
+ # @option options [Object] :* Any attribute configuration option
157
160
  #
158
161
  # @yield [self] Block to configure nested attributes
159
162
  #
@@ -238,6 +241,7 @@ module CMDx
238
241
  #
239
242
  # @param names [Array<Symbol, String>] The names of the child attributes
240
243
  # @param options [Hash] Configuration options for the child attributes
244
+ # @option options [Object] :* Any attribute configuration option
241
245
  #
242
246
  # @yield [self] Block to configure the child attributes
243
247
  #
@@ -257,6 +261,7 @@ module CMDx
257
261
  #
258
262
  # @param names [Array<Symbol, String>] The names of the optional child attributes
259
263
  # @param options [Hash] Configuration options for the child attributes
264
+ # @option options [Object] :* Any attribute configuration option
260
265
  #
261
266
  # @yield [self] Block to configure the child attributes
262
267
  #
@@ -274,6 +279,7 @@ module CMDx
274
279
  #
275
280
  # @param names [Array<Symbol, String>] The names of the required child attributes
276
281
  # @param options [Hash] Configuration options for the child attributes
282
+ # @option options [Object] :* Any attribute configuration option
277
283
  #
278
284
  # @yield [self] Block to configure the child attributes
279
285
  #
@@ -53,9 +53,9 @@ module CMDx
53
53
  # @param type [Symbol] The callback type from TYPES
54
54
  # @param callables [Array<#call>] Callable objects to register
55
55
  # @param options [Hash] Options to pass to the callback
56
+ # @param block [Proc] Optional block to register as a callable
56
57
  # @option options [Hash] :if Condition hash for conditional execution
57
58
  # @option options [Hash] :unless Inverse condition hash for conditional execution
58
- # @param block [Proc] Optional block to register as a callable
59
59
  #
60
60
  # @return [CallbackRegistry] self for method chaining
61
61
  #
@@ -83,6 +83,7 @@ module CMDx
83
83
  # @param callables [Array<#call>] Callable objects to remove
84
84
  # @param options [Hash] Options that were used during registration
85
85
  # @param block [Proc] Optional block to remove
86
+ # @option options [Object] :* Any option key-value pairs
86
87
  #
87
88
  # @return [CallbackRegistry] self for method chaining
88
89
  #
data/lib/cmdx/chain.rb CHANGED
@@ -39,9 +39,10 @@ module CMDx
39
39
  # @return [Chain] A new chain instance
40
40
  #
41
41
  # @rbs () -> void
42
- def initialize
42
+ def initialize(dry_run: false)
43
43
  @id = Identifier.generate
44
44
  @results = []
45
+ @dry_run = !!dry_run
45
46
  end
46
47
 
47
48
  class << self
@@ -102,24 +103,35 @@ module CMDx
102
103
  # puts "Chain size: #{chain.size}"
103
104
  #
104
105
  # @rbs (Result result) -> Chain
105
- def build(result)
106
+ def build(result, dry_run: false)
106
107
  raise TypeError, "must be a CMDx::Result" unless result.is_a?(Result)
107
108
 
108
- self.current ||= new
109
+ self.current ||= new(dry_run:)
109
110
  current.results << result
110
111
  current
111
112
  end
112
113
 
113
114
  end
114
115
 
115
- # Converts the chain to a hash representation.
116
+ # Returns whether the chain is running in dry-run mode.
116
117
  #
117
- # @return [Hash] Hash containing chain id and serialized results
118
+ # @return [Boolean] Whether the chain is running in dry-run mode
118
119
  #
119
- # @option return [String] :id The chain identifier
120
+ # @example
121
+ # chain.dry_run? # => true
120
122
  #
123
+ # @rbs () -> bool
124
+ def dry_run?
125
+ !!@dry_run
126
+ end
127
+
128
+ # Converts the chain to a hash representation.
129
+ #
130
+ # @option return [String] :id The chain identifier
121
131
  # @option return [Array<Hash>] :results Array of result hashes
122
132
  #
133
+ # @return [Hash] Hash containing chain id and serialized results
134
+ #
123
135
  # @example
124
136
  # chain_hash = chain.to_h
125
137
  # puts chain_hash[:id]
@@ -128,7 +140,8 @@ module CMDx
128
140
  # @rbs () -> Hash[Symbol, untyped]
129
141
  def to_h
130
142
  {
131
- id: id,
143
+ id:,
144
+ dry_run: dry_run?,
132
145
  results: results.map(&:to_h)
133
146
  }
134
147
  end
@@ -20,7 +20,7 @@ module CMDx
20
20
 
21
21
  # Initialize a new coercion registry.
22
22
  #
23
- # @param registry [Hash<Symbol, Class>, nil] optional initial registry hash
23
+ # @param registry [Hash{Symbol => Class}, nil] optional initial registry hash
24
24
  #
25
25
  # @example
26
26
  # registry = CoercionRegistry.new
@@ -95,6 +95,7 @@ module CMDx
95
95
  # @param task [Object] the task context for the coercion
96
96
  # @param value [Object] the value to coerce
97
97
  # @param options [Hash] additional options for the coercion
98
+ # @option options [Object] :* Any coercion option key-value pairs
98
99
  #
99
100
  # @return [Object] the coerced value
100
101
  #
@@ -11,10 +11,10 @@ module CMDx
11
11
  extend self
12
12
 
13
13
  # @rbs FALSEY: Regexp
14
- FALSEY = /^(false|f|no|n|0)$/i
14
+ FALSEY = /\A(false|f|no|n|0)\z/i
15
15
 
16
16
  # @rbs TRUTHY: Regexp
17
- TRUTHY = /^(true|t|yes|y|1)$/i
17
+ TRUTHY = /\A(true|t|yes|y|1)\z/i
18
18
 
19
19
  # Converts a value to a Boolean
20
20
  #
@@ -40,7 +40,7 @@ module CMDx
40
40
  #
41
41
  # @rbs (untyped value, ?Hash[Symbol, untyped] options) -> bool
42
42
  def call(value, options = {})
43
- case value.to_s.downcase
43
+ case value.to_s
44
44
  when FALSEY then false
45
45
  when TRUTHY then true
46
46
  else
@@ -14,6 +14,7 @@ module CMDx
14
14
  #
15
15
  # @param value [Object] The value to convert to Complex
16
16
  # @param options [Hash] Optional configuration parameters (currently unused)
17
+ # @option options [Object] :* Any configuration option (unused)
17
18
  #
18
19
  # @return [Complex] The converted Complex number value
19
20
  #
@@ -15,6 +15,7 @@ module CMDx
15
15
  #
16
16
  # @param value [Object] The value to coerce to a string
17
17
  # @param options [Hash] Optional configuration parameters (unused in this coercion)
18
+ # @option options [Object] :* Any configuration option (unused)
18
19
  #
19
20
  # @return [String] The coerced string value
20
21
  #
@@ -15,6 +15,7 @@ module CMDx
15
15
  #
16
16
  # @param value [Object] The value to coerce to a symbol
17
17
  # @param options [Hash] Optional configuration parameters (unused in this coercion)
18
+ # @option options [Object] :* Any configuration option (unused)
18
19
  #
19
20
  # @return [Symbol] The coerced symbol value
20
21
  #
@@ -160,7 +160,7 @@ module CMDx
160
160
 
161
161
  # Converts the configuration to a hash representation.
162
162
  #
163
- # @return [Hash<Symbol, Object>] hash containing all configuration values
163
+ # @return [Hash{Symbol => Object}] hash containing all configuration values
164
164
  #
165
165
  # @example
166
166
  # config = Configuration.new
@@ -205,8 +205,6 @@ module CMDx
205
205
 
206
206
  # Configures CMDx using a block that receives the configuration instance.
207
207
  #
208
- # @param block [Proc] the configuration block
209
- #
210
208
  # @yield [Configuration] the configuration instance to configure
211
209
  #
212
210
  # @return [Configuration] the configured configuration instance
data/lib/cmdx/context.rb CHANGED
@@ -109,7 +109,6 @@ module CMDx
109
109
  # Fetches a value from the context by key, with optional default value.
110
110
  #
111
111
  # @param key [String, Symbol] the key to fetch
112
- # @param default [Object] the default value if key is not found
113
112
  #
114
113
  # @yield [key] a block to compute the default value
115
114
  #
@@ -165,6 +164,7 @@ module CMDx
165
164
  args.to_h.each { |key, value| self[key.to_sym] = value }
166
165
  self
167
166
  end
167
+ alias merge merge!
168
168
 
169
169
  # Deletes a key-value pair from the context.
170
170
  #
@@ -183,6 +183,7 @@ module CMDx
183
183
  def delete!(key, &)
184
184
  table.delete(key.to_sym, &)
185
185
  end
186
+ alias delete delete!
186
187
 
187
188
  # Compares this context with another object for equality.
188
189
  #
@@ -255,6 +256,7 @@ module CMDx
255
256
  # @param method_name [Symbol] the method name that was called
256
257
  # @param args [Array<Object>] arguments passed to the method
257
258
  # @param _kwargs [Hash] keyword arguments (unused)
259
+ # @option _kwargs [Object] :* Any keyword arguments (unused)
258
260
  #
259
261
  # @yield [Object] optional block
260
262
  #
@@ -263,7 +265,8 @@ module CMDx
263
265
  # @rbs (Symbol method_name, *untyped args, **untyped _kwargs) ?{ () -> untyped } -> untyped
264
266
  def method_missing(method_name, *args, **_kwargs, &)
265
267
  fetch(method_name) do
266
- store(method_name[0..-2], args.first) if method_name.end_with?("=")
268
+ str_name = method_name.to_s
269
+ store(str_name.chop, args.first) if str_name.end_with?("=")
267
270
  end
268
271
  end
269
272
 
data/lib/cmdx/executor.rb CHANGED
@@ -8,6 +8,8 @@ module CMDx
8
8
  # and proper error handling for different types of failures.
9
9
  class Executor
10
10
 
11
+ extend Forwardable
12
+
11
13
  # Returns the task being executed.
12
14
  #
13
15
  # @return [Task] The task instance
@@ -18,6 +20,8 @@ module CMDx
18
20
  # @rbs @task: Task
19
21
  attr_reader :task
20
22
 
23
+ def_delegators :task, :result
24
+
21
25
  # @param task [CMDx::Task] The task to execute
22
26
  #
23
27
  # @return [CMDx::Executor] A new executor instance
@@ -65,13 +69,13 @@ module CMDx
65
69
  rescue UndefinedMethodError => e
66
70
  raise(e) # No need to clear the Chain since exception is not being re-raised
67
71
  rescue Fault => e
68
- task.result.throw!(e.result, halt: false, cause: e)
72
+ result.throw!(e.result, halt: false, cause: e)
69
73
  rescue StandardError => e
70
74
  retry if retry_execution?(e)
71
- task.result.fail!("[#{e.class}] #{e.message}", halt: false, cause: e)
75
+ result.fail!("[#{e.class}] #{e.message}", halt: false, cause: e)
72
76
  task.class.settings[:exception_handler]&.call(task, e)
73
77
  ensure
74
- task.result.executed!
78
+ result.executed!
75
79
  post_execution!
76
80
  end
77
81
 
@@ -96,14 +100,14 @@ module CMDx
96
100
  rescue UndefinedMethodError => e
97
101
  raise_exception(e)
98
102
  rescue Fault => e
99
- task.result.throw!(e.result, halt: false, cause: e)
103
+ result.throw!(e.result, halt: false, cause: e)
100
104
  halt_execution?(e) ? raise_exception(e) : post_execution!
101
105
  rescue StandardError => e
102
106
  retry if retry_execution?(e)
103
- task.result.fail!("[#{e.class}] #{e.message}", halt: false, cause: e)
107
+ result.fail!("[#{e.class}] #{e.message}", halt: false, cause: e)
104
108
  raise_exception(e)
105
109
  else
106
- task.result.executed!
110
+ result.executed!
107
111
  post_execution!
108
112
  end
109
113
 
@@ -137,14 +141,14 @@ module CMDx
137
141
  available_retries = (task.class.settings[:retries] || 0).to_i
138
142
  return false unless available_retries.positive?
139
143
 
140
- current_retries = (task.result.metadata[:retries] ||= 0).to_i
144
+ current_retries = (result.metadata[:retries] ||= 0).to_i
141
145
  remaining_retries = available_retries - current_retries
142
146
  return false unless remaining_retries.positive?
143
147
 
144
148
  exceptions = Array(task.class.settings[:retry_on] || StandardError)
145
149
  return false unless exceptions.any? { |e| exception.class <= e }
146
150
 
147
- task.result.metadata[:retries] += 1
151
+ result.metadata[:retries] += 1
148
152
 
149
153
  task.logger.warn do
150
154
  reason = "[#{exception.class}] #{exception.message}"
@@ -208,7 +212,7 @@ module CMDx
208
212
  task.class.settings[:attributes].define_and_verify(task)
209
213
  return if task.errors.empty?
210
214
 
211
- task.result.fail!(
215
+ result.fail!(
212
216
  Locale.t("cmdx.faults.invalid"),
213
217
  errors: {
214
218
  full_message: task.errors.to_s,
@@ -223,7 +227,7 @@ module CMDx
223
227
  def execution!
224
228
  invoke_callbacks(:before_execution)
225
229
 
226
- task.result.executing!
230
+ result.executing!
227
231
  task.work
228
232
  end
229
233
 
@@ -231,12 +235,12 @@ module CMDx
231
235
  #
232
236
  # @rbs () -> void
233
237
  def post_execution!
234
- invoke_callbacks(:"on_#{task.result.state}")
235
- invoke_callbacks(:on_executed) if task.result.executed?
238
+ invoke_callbacks(:"on_#{result.state}")
239
+ invoke_callbacks(:on_executed) if result.executed?
236
240
 
237
- invoke_callbacks(:"on_#{task.result.status}")
238
- invoke_callbacks(:on_good) if task.result.good?
239
- invoke_callbacks(:on_bad) if task.result.bad?
241
+ invoke_callbacks(:"on_#{result.status}")
242
+ invoke_callbacks(:on_good) if result.good?
243
+ invoke_callbacks(:on_bad) if result.bad?
240
244
  end
241
245
 
242
246
  # Finalizes execution by freezing the task, logging results, and rolling back work.
@@ -246,26 +250,25 @@ module CMDx
246
250
  log_execution!
247
251
  log_backtrace! if task.class.settings[:backtrace]
248
252
 
253
+ rollback_execution!
249
254
  freeze_execution!
250
255
  clear_chain!
251
-
252
- rollback_execution!
253
256
  end
254
257
 
255
258
  # Logs the execution result at the configured log level.
256
259
  #
257
260
  # @rbs () -> void
258
261
  def log_execution!
259
- task.logger.info { task.result.to_h }
262
+ task.logger.info { result.to_h }
260
263
  end
261
264
 
262
265
  # Logs the backtrace of the exception if the task failed.
263
266
  #
264
267
  # @rbs () -> void
265
268
  def log_backtrace!
266
- return unless task.result.failed?
269
+ return unless result.failed?
267
270
 
268
- exception = task.result.caused_failure.cause
271
+ exception = result.caused_failure.cause
269
272
  return if exception.is_a?(Fault)
270
273
 
271
274
  task.logger.error do
@@ -287,11 +290,11 @@ module CMDx
287
290
  return if Coercions::Boolean.call(skip_freezing)
288
291
 
289
292
  task.freeze
290
- task.result.freeze
293
+ result.freeze
291
294
 
292
295
  # Freezing the context and chain can only be done
293
296
  # once the outer-most task has completed.
294
- return unless task.result.index.zero?
297
+ return unless result.index.zero?
295
298
 
296
299
  task.context.freeze
297
300
  task.chain.freeze
@@ -301,7 +304,7 @@ module CMDx
301
304
  #
302
305
  # @rbs () -> void
303
306
  def clear_chain!
304
- return unless task.result.index.zero?
307
+ return unless result.index.zero?
305
308
 
306
309
  Chain.clear
307
310
  end
@@ -310,12 +313,14 @@ module CMDx
310
313
  #
311
314
  # @rbs () -> void
312
315
  def rollback_execution!
316
+ return if result.rolled_back?
313
317
  return unless task.respond_to?(:rollback)
314
318
 
315
319
  statuses = task.class.settings[:rollback_on]
316
320
  statuses = Array(statuses).map(&:to_s).uniq
317
- return unless statuses.include?(task.result.status)
321
+ return unless statuses.include?(result.status)
318
322
 
323
+ result.rolled_back!
319
324
  task.rollback
320
325
  end
321
326
 
@@ -109,6 +109,7 @@ module CMDx
109
109
  #
110
110
  # @param index [Integer] Current middleware index in the chain
111
111
  # @param task [Object] The task object being processed
112
+ # @param block [Proc] Block to execute after middleware processing
112
113
  #
113
114
  # @yield [task] Block to execute after middleware processing
114
115
  # @yieldparam task [Object] The processed task object