Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),

## [UNRELEASED]

### Added
- Added `rollback` ability to undo on matching status

## [1.9.1] - 2025-10-22

### Added
Expand Down
17 changes: 17 additions & 0 deletions docs/basics/setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,22 @@ end
IncompleteTask.execute #=> raises CMDx::UndefinedMethodError
```

## Rollback

Undo any operations linked to the given status, helping to restore a pristine state.

```ruby
class ValidateDocument < CMDx::Task
def work
# Your logic here...
end

def rollback
# Your undo logic...
end
end
```

## Inheritance

Share configuration across tasks using inheritance:
Expand Down Expand Up @@ -65,3 +81,4 @@ Tasks follow a predictable execution pattern:
| **Execution** | `executing` | `success`/`failed`/`skipped` | `work` method runs |
| **Completion** | `executed` | `success`/`failed`/`skipped` | Result finalized |
| **Freezing** | `executed` | `success`/`failed`/`skipped` | Task becomes immutable |
| **Rollback** | `executed` | `failed`/`skipped` | Work undone |
12 changes: 12 additions & 0 deletions docs/getting_started.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,16 @@ CMDx.configure do |config|
end
```

### Rollpoints

Control when a `rollback` of task execution is called.

```ruby
CMDx.configure do |config|
config.task_rollpoints = ["failed"] # String or Array[String]
end
```

### Backtraces

Enable detailed backtraces for non-fault exceptions to improve debugging. Optionally clean up stack traces to remove framework noise.
Expand Down Expand Up @@ -245,13 +255,15 @@ class GenerateInvoice < CMDx::Task
settings(
# Global configuration overrides
task_breakpoints: ["failed"], # Breakpoint override
task_rollpoints: ["failed", "skipped"], # Rollpoint override
workflow_breakpoints: [], # Breakpoint override
backtrace: true, # Toggle backtrace
backtrace_cleaner: ->(bt) { bt[0..5] }, # Backtrace cleaner
logger: CustomLogger.new($stdout), # Custom logger

# Task configuration settings
breakpoints: ["failed"], # Contextual pointer for :task_breakpoints and :workflow_breakpoints
rollpoints: ["failed", "skipped"], # Contextual pointer for :task_rollpoints
log_level: :info, # Log level override
log_formatter: CMDx::LogFormatters::Json.new # Log formatter override
tags: ["billing", "financial"], # Logging tags
Expand Down
15 changes: 15 additions & 0 deletions lib/cmdx/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ class Configuration
# @rbs DEFAULT_BREAKPOINTS: Array[String]
DEFAULT_BREAKPOINTS = %w[failed].freeze

# @rbs DEFAULT_ROLLPOINTS: Array[String]
DEFAULT_ROLLPOINTS = %w[failed].freeze

# Returns the middleware registry for task execution.
#
# @return [MiddlewareRegistry] The middleware registry
Expand Down Expand Up @@ -59,6 +62,16 @@ class Configuration
# @rbs @task_breakpoints: Array[String]
attr_accessor :task_breakpoints

# Returns the statuses that trigger a task execution rollback.
#
# @return [Array<String>] Array of status names that trigger rollback
#
# @example
# config.task_rollpoints = ["failed", "skipped"]
#
# @rbs @task_rollpoints: Array[String]
attr_accessor :task_rollpoints

# Returns the breakpoint statuses for workflow execution interruption.
#
# @return [Array<String>] Array of status names that trigger breakpoints
Expand Down Expand Up @@ -130,6 +143,7 @@ def initialize
@validators = ValidatorRegistry.new

@task_breakpoints = DEFAULT_BREAKPOINTS
@task_rollpoints = DEFAULT_ROLLPOINTS
@workflow_breakpoints = DEFAULT_BREAKPOINTS

@backtrace = false
Expand Down Expand Up @@ -161,6 +175,7 @@ def to_h
coercions: @coercions,
validators: @validators,
task_breakpoints: @task_breakpoints,
task_rollpoints: @task_rollpoints,
workflow_breakpoints: @workflow_breakpoints,
backtrace: @backtrace,
backtrace_cleaner: @backtrace_cleaner,
Expand Down
32 changes: 19 additions & 13 deletions lib/cmdx/executor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -118,15 +118,12 @@ def execute!
#
# @return [Boolean] Whether execution should halt
#
# @example
# halt_execution?(fault_exception)
#
# @rbs (Exception exception) -> bool
def halt_execution?(exception)
breakpoints = task.class.settings[:breakpoints] || task.class.settings[:task_breakpoints]
breakpoints = Array(breakpoints).map(&:to_s).uniq
statuses = task.class.settings[:breakpoints] || task.class.settings[:task_breakpoints]
statuses = Array(statuses).map(&:to_s).uniq

breakpoints.include?(exception.result.status)
statuses.include?(exception.result.status)
end

# Determines if execution should be retried based on retry configuration.
Expand All @@ -135,9 +132,6 @@ def halt_execution?(exception)
#
# @return [Boolean] Whether execution should be retried
#
# @example
# retry_execution?(standard_error)
#
# @rbs (Exception exception) -> bool
def retry_execution?(exception)
available_retries = (task.class.settings[:retries] || 0).to_i
Expand Down Expand Up @@ -169,9 +163,6 @@ def retry_execution?(exception)
#
# @raise [Exception] The provided exception
#
# @example
# raise_exception(standard_error)
#
# @rbs (Exception exception) -> void
def raise_exception(exception)
Chain.clear
Expand Down Expand Up @@ -244,7 +235,7 @@ def post_execution!
invoke_callbacks(:on_bad) if task.result.bad?
end

# Finalizes execution by freezing the task and logging results.
# Finalizes execution by freezing the task, logging results, and rolling back work.
#
# @rbs () -> Result
def finalize_execution!
Expand All @@ -253,6 +244,8 @@ def finalize_execution!

freeze_execution!
clear_chain!

rollback_execution!
end

# Logs the execution result at the configured log level.
Expand Down Expand Up @@ -309,5 +302,18 @@ def clear_chain!
Chain.clear
end

# Rolls back the work of a task.
#
# @rbs () -> void
def rollback_execution!
return unless task.respond_to?(:rollback)

statuses = task.class.settings[:rollpoints] || task.class.settings[:task_rollpoints]
statuses = Array(statuses).map(&:to_s).uniq
return unless statuses.include?(task.result.status)

task.rollback
end

end
end
127 changes: 127 additions & 0 deletions spec/cmdx/executor_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -562,4 +562,131 @@
end
end
end

describe "#rollback_execution!" do
context "when task does not respond to rollback" do
before do
allow(task).to receive(:respond_to?).with(:rollback).and_return(false)
allow(task.result).to receive(:status).and_return("failed")
end

it "does not call rollback" do
expect(task).not_to receive(:rollback)

worker.send(:rollback_execution!)
end
end

context "when task responds to rollback" do
before do
allow(task).to receive(:respond_to?).with(:rollback).and_return(true)
allow(task).to receive(:rollback)
end

context "when rollpoints setting exists" do
before do
allow(task.class).to receive(:settings).and_return({ rollpoints: %w[failed skipped] })
end

context "when result status is in rollpoints" do
before do
allow(task.result).to receive(:status).and_return("failed")
end

it "calls rollback" do
expect(task).to receive(:rollback)

worker.send(:rollback_execution!)
end
end

context "when result status is not in rollpoints" do
before do
allow(task.result).to receive(:status).and_return("success")
end

it "does not call rollback" do
expect(task).not_to receive(:rollback)

worker.send(:rollback_execution!)
end
end
end

context "when task_rollpoints setting exists" do
before do
allow(task.class).to receive(:settings).and_return({ task_rollpoints: [:failed] })
end

it "converts symbols to strings and calls rollback" do
allow(task.result).to receive(:status).and_return("failed")

expect(task).to receive(:rollback)

worker.send(:rollback_execution!)
end
end

context "when no rollpoints are configured" do
before do
allow(task.class).to receive(:settings).and_return({})
allow(task.result).to receive(:status).and_return("failed")
end

it "does not call rollback" do
expect(task).not_to receive(:rollback)

worker.send(:rollback_execution!)
end
end

context "when rollpoints is nil" do
before do
allow(task.class).to receive(:settings).and_return({ rollpoints: nil })
allow(task.result).to receive(:status).and_return("failed")
end

it "does not call rollback" do
expect(task).not_to receive(:rollback)

worker.send(:rollback_execution!)
end
end

context "with duplicate rollpoints" do
before do
allow(task.class).to receive(:settings).and_return({ rollpoints: ["failed", "failed", :failed] })
allow(task.result).to receive(:status).and_return("failed")
end

it "removes duplicates after string conversion and calls rollback" do
expect(task).to receive(:rollback).once

worker.send(:rollback_execution!)
end
end

context "with multiple statuses in rollpoints" do
before do
allow(task.class).to receive(:settings).and_return({ rollpoints: %w[failed skipped] })
end

it "calls rollback when status is failed" do
allow(task.result).to receive(:status).and_return("failed")

expect(task).to receive(:rollback)

worker.send(:rollback_execution!)
end

it "calls rollback when status is skipped" do
allow(task.result).to receive(:status).and_return("skipped")

expect(task).to receive(:rollback)

worker.send(:rollback_execution!)
end
end
end
end
end