Summary#
await Workflow.Run("child", child) waits for a workflow to finish. If it finishes badly — at a terminal cancel or a
terminal error — that outcome arrives here as WorkflowCancelled or WorkflowError.
You have exactly two choices, and both are things you say in the code:
catchit — cancellation or failure stops here, and you decide what happens next.- Don't — it keeps going. This run ends at its own
terminal cancel/terminal error, and whoever is waiting on this run receives the same kind in turn.
Not catching is a real choice, not an oversight. "If the work I'm waiting on is cancelled, I'm cancelled" is usually exactly right, and it is the same thing C# does with a cancellation that reaches the top of a task.
Signature#
try { await Workflow.Run("child", child); goto Done; }
catch (WorkflowCancelled e) { goto Withdrawn; } // handle it — it stops here
catch (WorkflowError e) { goto Rejected; }
terminal cancel Withdrawn { Message = "…"; } // …or declare where an UNCAUGHT one lands
terminal error Rejected { Message = "…"; }e.Message is the terminal's own Message — the sentence the workflow that ended wrote about why.
Description#
The two kinds mean different things, and the difference survives the trip#
WorkflowCancelled is a deliberate stop: the work was called off. WorkflowError is a failure: it went wrong.
Keeping them apart is the whole reason there are two, and it matters most far from where it happened — an approval
three workflows up should react differently to "the customer withdrew" than to "the payment provider rejected it".
So the kind is preserved every step of the way. A cancellation that travels up four runs is still a cancellation when
it gets there, and the fourth run's catch (WorkflowCancelled e) is what fires.
Where an uncaught one lands, and why you have to say#
An uncaught outcome ends the run at the terminal you declared for it — a real transition: the tracked property
moves, the audit records it, and the terminal's Message is what the next run up receives.
That is why the compiler asks you to declare one. If a wait can be cancelled and nothing catches it, the run is going to end there, and a run has to end somewhere you named. The rule is narrow on purpose — you are only asked when both are true:
- nothing catches it on that path, and
- the workflow you are waiting on can actually produce that outcome.
A child that declares no terminal cancel can never be cancelled, so you are never asked to declare a cancel terminal
for it. You will not be made to write states that can't be reached.
Declaring two terminals of the same kind is also an error: an uncaught outcome would have two places to land, and
the compiler will not guess. Keep one, or catch it and goto the one you mean.
You cannot raise these yourself#
WorkflowCancelled and WorkflowError are raised by the platform, and only by the platform. throwing one is a
compile error.
They mean one precise thing — a workflow I waited for ended at a declared terminal — and everything above depends on
that staying true. To end your own workflow that way, goto its terminal: that is declared, audited, moves the
tracked property, and carries a Message. To fail an ordinary function, throw new Exception("…").
Examples#
A child that can end all three ways, and a parent that handles one of them and lets the other travel:
enum OrderStatus { Placed, Shipped, Rejected, Withdrawn }
enum PickStatus { Waiting, Picked, Empty, Cancelled }
entity Order { [Required, MaxLength(40)] string Reference; OrderStatus Status = OrderStatus.Placed; }
entity Pick { [Required] Order Order; PickStatus Status = PickStatus.Waiting; }
workflow PickFlow {
Tracks = Pick.Status; Autostart = false; Initial = Waiting;
event Report(bool inStock);
state Waiting {
subscribe Report(bool inStock);
on Report(bool inStock) {
when (inStock) { goto Picked; }
default { goto Empty; }
}
}
terminal success Picked { }
terminal error Empty { Message = "out of stock"; }
terminal cancel Cancelled { Message = "picking called off"; }
}
workflow OrderFlow {
Tracks = Order.Status; Autostart = false; Initial = Placed;
state Placed {
enter {
var pick = Workflow.Once("make-pick", () => new Pick { Order = this.Item });
try { await Workflow.Run("pick", pick); goto Shipped; }
catch (WorkflowError e) { goto Rejected; } // a FAILURE stops here — we reject the order
}
}
terminal success Shipped { }
terminal success Rejected { }
// A CANCELLATION is deliberately not caught: if picking was called off, the order is withdrawn. This is the
// terminal that says where it lands — and without it, the code above would not compile.
terminal cancel Withdrawn { Message = "order withdrawn"; }
}Read the Placed state as a sentence: ship it if the pick succeeds; reject it if the pick fails; if the pick is
called off, so is the order. The third clause is the terminal, not a catch.
Notes#
- Only these two outcomes behave this way. An ordinary failure — a dropped connection, a timeout, a bug — is not a workflow outcome, and the platform retries it rather than ending the run, because another attempt may well succeed. A cancelled or failed child is different: it has already finished, so trying again would read the same answer.
- Compensations still run. If the run had a Workflow.BeginSaga (a compensating saga scope) scope open, leaving through an uncaught outcome disposes it on the way out, so the work it registered is undone before the run ends.
- A
catch (Exception e)catches these too, like any other. Reach for the specific ones when the difference between "called off" and "went wrong" changes what you do.
See also#
- Workflow.Run (start a workflow) — start a workflow, and optionally wait for it.
- Workflow.BeginSaga (a compensating saga scope) — undoing work when a wait ends badly.
- Parallel legs (start several, then wait for them) — waiting on several at once, and what happens when one of them fails.