Interruptions - Signals¶
Sometimes you already know how a task should end before you finish the method. CMDx gives you four friendly "stop here" helpers: success!, skip!, fail!, and throw!. Each one says what you mean, and you can attach a human-readable reason plus extra data for logs or APIs.
Under the hood they throw a CMDx::Signal. The runtime catches that around work, so execution jumps out right away — nothing below the halt line runs.
Note
success! is the "happy stop" with a custom reason and metadata. For the full picture, see Annotating a Successful Result.
Skipping¶
Use skip! when the task does not need to do anything useful right now. That is a deliberate choice, not a bug.
Heads up
A skip still counts as an OK outcome (result.ok? is true). execute! only raises on a real failure — it stays quiet on skips.
class ProcessInventory < CMDx::Task
def work
# Without a reason
skip! if Array(ENV["DISABLED_TASKS"]).include?(self.class.name)
# With a reason
skip!("Warehouse closed") unless Time.now.hour.between?(8, 18)
inventory = Inventory.find(context.inventory_id)
if inventory.already_counted?
skip!("Inventory already counted today")
else
inventory.count!
end
end
end
result = ProcessInventory.execute(inventory_id: 456)
# Executed
result.status #=> "skipped"
# Without a reason
result.reason #=> nil
# With a reason
result.reason #=> "Warehouse closed"
Failing¶
Use fail! when the task cannot honestly finish as a success. You stay in control: you chose to stop, you pick the message.
class ProcessRefund < CMDx::Task
def work
# Without a reason
fail! if Array(ENV["DISABLED_TASKS"]).include?(self.class.name)
refund = Refund.find(context.refund_id)
# With a reason
if refund.expired?
fail!("Refund period has expired")
elsif !refund.amount.positive?
fail!("Refund amount must be positive")
else
refund.process!
end
end
end
result = ProcessRefund.execute(refund_id: 789)
# Executed
result.status #=> "failed"
# Without a reason
result.reason #=> nil
# With a reason
result.reason #=> "Refund period has expired"
Note
result.reason is whatever string you passed (or nil). The generic "unspecified" text only shows up on Fault#message when execute! raises and you never gave a reason.
Metadata Enrichment¶
Want more than a string? Pass keyword arguments. They merge into Task#metadata, then ride along on the signal. If middleware already stuffed a request id into task.metadata, it still shows up — you do not have to copy it by hand.
class ProcessRenewal < CMDx::Task
def work
license = License.find(context.license_id)
if license.already_renewed?
# Without metadata
skip!("License already renewed")
end
unless license.renewal_eligible?
# With metadata
fail!(
"License not eligible for renewal",
error_code: "LICENSE.NOT_ELIGIBLE",
retry_after: Time.current + 30.days
)
end
process_renewal
end
end
result = ProcessRenewal.execute(license_id: 567)
# Without metadata
result.metadata #=> {}
# With metadata
result.metadata #=> {
# error_code: "LICENSE.NOT_ELIGIBLE",
# retry_after: <Time 30 days from now>
# }
Short-Circuit Behavior¶
Halt helpers always throw. They never "return" to the next line in work. The first one that runs wins; anything after it is dead code until the method ends.
class ProcessOrder < CMDx::Task
def work
fail!("Out of stock") if out_of_stock?
fail!("Insufficient funds") if insufficient_funds?
# If both are true, only the first fail! runs.
end
end
Where they work
These halt cleanly from work, callbacks, and middlewares (see Halting from middleware). Throwing from #rollback or on a frozen task raises CMDx::FrozenTaskError.
State Transitions¶
Each halt maps to a simple combo of state, status, and how ok? / ko? read:
| Method | State | Status | Outcome |
|---|---|---|---|
success! |
complete |
success |
ok? = true, ko? = false |
skip! |
interrupted |
skipped |
ok? = true, ko? = true |
fail! |
interrupted |
failed |
ok? = false, ko? = true |
throw!(failed) |
interrupted |
failed (mirrors upstream) |
ok? = false, ko? = true |
result = ProcessRenewal.execute(license_id: 567)
# State information
result.state #=> "interrupted"
result.status #=> "skipped" or "failed"
result.interrupted? #=> true
result.complete? #=> false
# Outcome categorization
result.ok? #=> true for skipped, false for failed
result.ko? #=> true for both skipped and failed
Execution Behavior¶
execute always hands you a Result, whether work ran to the end or stopped on a signal. execute! only raises when the outcome is a failure; skips and custom successes return normally. For the full contract see Basics - Execution. For what actually gets raised, see Interruptions - Faults.
Rethrowing a Peer Failure¶
throw! is your "echo this other task's failure" button. If the other result is not failed, nothing happens and you keep going.
class ReportMonthlyMetrics < CMDx::Task
def work
result = BuildReport.execute(context)
throw!(result) # Copies the peer's state/status/reason; see `origin` for upstream.
# Metadata on *this* result is this task's metadata (plus any kwargs
# you pass to throw!), not a copy of the peer's hash.
# Happy path continues here when result isn't failed
end
end
The Result you get back keeps the upstream failure in result.origin, and result.thrown_failure? is true when you echoed a failure. More detail in Result - Chain Analysis.
Note
throw! accepts a Result or a raw CMDx::Signal. If the input is not failed, it is a no-op. Handy when you want to forward a halt without unpacking it yourself.
Best Practices¶
Specific reasons make everyone's life easier: they land on result.reason, in Fault#message, and in logs.
# Best: specific reason + structured metadata
fail!("File format not supported by processor", code: "FORMAT_UNSUPPORTED")
# Good: clear reason
skip!("Document processing paused for compliance review")
# Avoid: nil reason (Fault#message falls back to localized cmdx.reasons.unspecified)
skip!
fail!
Manual Errors¶
You can also push errors onto task.errors as you go. If any are still there when work returns, the runtime turns that into a failed signal for you — no explicit fail! needed.
class ProcessRenewal < CMDx::Task
def work
document = Document.find(context.document_id)
errors.add(:document, "is not renewable") if document.nonrenewable?
document.renew! if errors.empty?
end
end
See Outcomes - Errors for the full errors API.