diff --git a/CHANGELOG.md b/CHANGELOG.md index c3a175277..1eb58a405 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/basics/setup.md b/docs/basics/setup.md index 273e0abc8..070e071f5 100644 --- a/docs/basics/setup.md +++ b/docs/basics/setup.md @@ -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: @@ -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 | diff --git a/docs/getting_started.md b/docs/getting_started.md index aa695c197..0706956a3 100644 --- a/docs/getting_started.md +++ b/docs/getting_started.md @@ -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. @@ -245,6 +255,7 @@ 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 @@ -252,6 +263,7 @@ class GenerateInvoice < CMDx::Task # 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 diff --git a/lib/cmdx/configuration.rb b/lib/cmdx/configuration.rb index bcaf6ce42..af04ec813 100644 --- a/lib/cmdx/configuration.rb +++ b/lib/cmdx/configuration.rb @@ -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 @@ -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] 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] Array of status names that trigger breakpoints @@ -130,6 +143,7 @@ def initialize @validators = ValidatorRegistry.new @task_breakpoints = DEFAULT_BREAKPOINTS + @task_rollpoints = DEFAULT_ROLLPOINTS @workflow_breakpoints = DEFAULT_BREAKPOINTS @backtrace = false @@ -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, diff --git a/lib/cmdx/executor.rb b/lib/cmdx/executor.rb index bf90aaee4..ebdea7b06 100644 --- a/lib/cmdx/executor.rb +++ b/lib/cmdx/executor.rb @@ -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. @@ -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 @@ -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 @@ -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! @@ -253,6 +244,8 @@ def finalize_execution! freeze_execution! clear_chain! + + rollback_execution! end # Logs the execution result at the configured log level. @@ -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 diff --git a/spec/cmdx/executor_spec.rb b/spec/cmdx/executor_spec.rb index 70d128a29..fe3185f38 100644 --- a/spec/cmdx/executor_spec.rb +++ b/spec/cmdx/executor_spec.rb @@ -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