Osy#betaRun it on Osyrin: osyrin.com

Reference / Workflow

When a child is cancelled or fails

catch (WorkflowCancelled e) { … } · catch (WorkflowError e) { … } · terminal cancel X { } · terminal error X { }

A workflow you waited for can end badly — cancelled, or failed. `catch` is how you handle it; not catching it is how you let it travel onward, and the run then ends at the `terminal cancel` or `terminal error` you declared. The two outcomes stay distinct however far they travel, so a workflow three levels up can still tell "the work below me was cancelled" from "it broke".

stable1 example compiled by CIworkflowauthoring

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:

  • catch it — 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:

using Osysharp.Workflow;

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.
  • A write the database refuses is not retried. When a run's start has done its work and the commit is refused — a [Unique] value already taken, a value longer than its field — another attempt would be refused the same way, so the run ends failed there, with the database's own words on it. A lost race (somebody else's write got there first) is retried like any other ordinary failure. Either way the fault is recorded on the run and logged, so osy inspect and osy logs both show it.
  • 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#

Related

Workflow.Run (start a workflow)

Start the workflow bound to an entity's type, on that entity. Bare — `Workflow.Run(order)` — is fire-and-forget: start…

Workflow.BeginSaga (a compensating saga scope)

Open a saga scope that couples each forward step with its compensation. `await saga.Run("step", step, () => Undo(…))`…

Parallel legs (start several, then wait for them)

Start several pieces of work at once, each with its own compensation, and wait for them together. Writing…