Osy#the first language built for agents
Agents firstAgentic appsWorkflowsDurable Execution — built inSecurityTestingThe editorThe UI modelOne program

Reference / Workflow

Workflow.BeginSaga (a compensating saga scope)

await using var saga = Workflow.BeginSaga(); · await saga.Run("step", step, () => Undo(…)) · saga.OnUnwind(() => Undo(…)) · saga.Complete()

Open a saga scope that couples each forward step with its compensation. `await saga.Run("step", step, () => Undo(…))` runs a child-workflow step and registers its undo in one call; if a later step fails, disposing the scope without `saga.Complete()` runs the registered undos in reverse. It is the C# `TransactionScope` idiom — `await using` + a durable undo stack — for a multi-step saga whose plain `try`/`catch` would otherwise force a compensation staircase.

stable2 examples compiled by CIworkflowauthoringsaga

Summary#

Workflow.BeginSaga() opens a saga scope — a durable transactional scope for a multi-step saga. Its one load-bearing idea is coupling: await saga.Run("step", step, () => Undo(step)) runs a step and registers its compensation in a single call, so a step can never run without its undo. If any later step fails, disposing the scope without calling saga.Complete() runs every registered undo in reverse order.

Reach for it only for the complex case. A simple saga — one step, one compensation — is cleaner as a plain try/catch around Workflow.Run (start a workflow) (nothing to couple). A saga with N steps that must unwind in reverse is where pure try/catch forces a compensation staircase — each step's catch re-listing every earlier undo — and that duplicated, hand-ordered list is exactly the bug-farm the scope removes.

Signature#

await using var saga = Workflow.BeginSaga();   // open the scope; dispose-without-Complete unwinds, in reverse
await saga.Run("step", step, () => Undo(args));        // run a child-workflow step AND register its compensation
await saga.Run("step", step);                          // a step with no compensation (e.g. the last one)
saga.OnUnwind(() => Undo(args));               // register a compensation with NO forward step
saga.Complete();                               // every step stood → drop the undo stack (dispose becomes a no-op)

step is an entity-typed value whose type has exactly one workflow bound to it — the same inference Workflow.Run (start a workflow) uses. A compensation lambda's body is a single call: an app function (() => VoidAuth(auth)) or a compensating child workflow (() => Workflow.Run(new ReturnShipment { Shipment = s })).

Description#

await using var saga = Workflow.BeginSaga(); is a C# using declaration: the scope is disposed at the end of the enclosing block, on every exit path — normal fall-through, a goto to a terminal, or a thrown fault. Disposal runs the saga's registered compensations unless saga.Complete() was reached first.

Each step registers its undo, and only committed steps compensate. await saga.Run("step", step, () => Undo(step)) starts the child workflow bound to step and waits for it (a durable wait — the flow parks and resumes exactly like an awaited Workflow.Run (start a workflow)). On the step's success, its undo is pushed onto the scope's stack; on a failure, the call throws WorkflowError (or WorkflowCancelled) and registers nothing — a step that never committed is never compensated. So a failing step propagates its fault to your catch, and the scope unwinds the steps that did stand.

Reverse order is automatic, and disposal runs before the terminal transition. The undo stack is LIFO, so compensations run newest-first. Because a workflow goto is deferred to the end of the body, the await using disposal runs its compensations before the routing goto takes effect — you compensate, then route, with no explicit unwind call.

A compensation is a function or a child workflow. Same step-granularity rule as a forward step: a DB-only or single-call undo is an app function the saga names; a multi-step, waiting undo (a real return: pickup → inspect → refund) is a child workflow (() => Workflow.Run(new Return { … })). A child-workflow undo waits too — disposal parks on it and resumes when it terminals, then continues unwinding the rest of the stack.

saga.OnUnwind(() => Undo(…)) registers a compensation that has no forward step — useful when something you did outside a saga.Run (a side effect earlier in the body) still needs undoing if the saga rolls back. It pushes onto the same stack, in call order.

saga.Complete() marks the saga successful: it drops the undo stack, so the await using disposal becomes a no-op. Call it once every step has stood, just before you route to the success terminal.

The scope is durable. The undo stack rides the parked continuation, so a saga that waits across steps (or across a restart) resumes with its registered compensations intact.

Examples#

A four-step fulfillment saga, shown as the complete app the docs gate compiles. Each step is a child workflow that waits on the outside world; the first three couple a plain-function compensation, the shipment step's compensation is itself a child workflow (a real multi-step return), and saga.OnUnwind registers one more undo that has no forward step. If any step fails, the committed steps unwind in reverse and the order routes to the error terminal.

First the world the saga runs in — the order and its customer, the four workflow-bound step entities, the compensating ReturnShipment child, and the compensation functions the saga names:

enum OStatus     { Received, Shipped, Rejected, Cancelled }
enum StepStatus  { Waiting, Done }

entity Customer { [Required, MaxLength(60)] string Name; decimal LastRefund; }
entity Order    { [Required] Customer Customer; decimal Total; OStatus Status = OStatus.Received; }

entity PaymentAuth          { [Required] Order Order; decimal Amount; StepStatus Status = StepStatus.Waiting; bool Undone; }
entity InventoryReservation { [Required] Order Order; StepStatus Status = StepStatus.Waiting; bool Undone; }
entity WarehousePick        { [Required] Order Order; StepStatus Status = StepStatus.Waiting; bool Undone; }
entity Shipment             { [Required] Order Order; StepStatus Status = StepStatus.Waiting; }
entity ReturnShipment       { [Required] Shipment Shipment; StepStatus Status = StepStatus.Waiting; }

void VoidAuth(PaymentAuth auth)                        { auth.Undone = true; }
void ReleaseReservation(InventoryReservation reserve)  { reserve.Undone = true; }
void CancelPick(WarehousePick pick)                    { pick.Undone = true; }
void RevokeLoyalty(Customer customer, decimal amount)  { customer.LastRefund = amount; }

workflow PaymentAuthFlow { Tracks = PaymentAuth.Status; Autostart = false; Initial = Waiting;
  event Finish(); state Waiting { subscribe Finish(); on Finish { goto Done; } } terminal success Done { } }
workflow InventoryReservationFlow { Tracks = InventoryReservation.Status; Autostart = false; Initial = Waiting;
  event Finish(); state Waiting { subscribe Finish(); on Finish { goto Done; } } terminal success Done { } }
workflow WarehousePickFlow { Tracks = WarehousePick.Status; Autostart = false; Initial = Waiting;
  event Finish(); state Waiting { subscribe Finish(); on Finish { goto Done; } } terminal success Done { } }
workflow ShipmentFlow { Tracks = Shipment.Status; Autostart = false; Initial = Waiting;
  event Finish(); state Waiting { subscribe Finish(); on Finish { goto Done; } } terminal success Done { } }
workflow ReturnShipmentFlow { Tracks = ReturnShipment.Status; Autostart = false; Initial = Waiting;
  event Finish(); state Waiting { subscribe Finish(); on Finish { goto Done; } } terminal success Done { } }

Then the saga itself — one await using scope in the order workflow's enter body, coupling each step with its undo:

workflow OrderFlow {
  Tracks = Order.Status; Autostart = false; Initial = Received;
  state Received {
    enter {
      await using var saga = Workflow.BeginSaga();          // dispose-without-Complete unwinds, in reverse
      try {
        var auth = Workflow.Once("make-auth", () => new PaymentAuth { Order = this.Item, Amount = this.Item.Total });
        await saga.Run("auth", auth, () => VoidAuth(auth));          // run step 1 + register its undo, atomically

        var reservation = Workflow.Once("make-reservation", () => new InventoryReservation { Order = this.Item });
        await saga.Run("reservation", reservation, () => ReleaseReservation(reservation));

        var pick = Workflow.Once("make-pick", () => new WarehousePick { Order = this.Item });
        await saga.Run("pick", pick, () => CancelPick(pick));

        var shipment = Workflow.Once("make-shipment", () => new Shipment { Order = this.Item });
        await saga.Run("shipment", shipment, () => Workflow.Run(new ReturnShipment { Shipment = shipment }));  // undo = a child workflow

        saga.OnUnwind(() => RevokeLoyalty(this.Item.Customer, this.Item.Total));   // an undo with no forward step

        saga.Complete();                                     // every step stood → drop the stack
        goto Shipped;
      } catch (WorkflowError e)     { goto Rejected; }        // dispose unwinds the committed steps (reverse), then routes
        catch (WorkflowCancelled e) { goto Cancelled; }
    }
  }
  terminal success Shipped   { }
  terminal error   Rejected  { Message = "order rejected"; }
  terminal error   Cancelled { Message = "order cancelled"; }
}

Notes#

  • Simple vs complex. One step, one compensation → a plain try/catch around Workflow.Run (start a workflow) is cleaner; the scope earns its place only when N steps must unwind in reverse (it removes the compensation staircase).
  • Couple the undo with the step. await saga.Run("step", step, undo) is the point — you cannot run a step and forget its compensation, and you never hand-maintain a reverse-ordered undo list.
  • Only committed steps compensate. A step that throws registered no undo, so it is not compensated; the steps before it are.
  • await is the wait. await saga.Run("step", ...) and await saga.DisposeUnwind() (the disposal the await using generates) are durable waits — the flow parks and resumes, even across a restart. saga.OnUnwind and saga.Complete are not waits.
  • Complete() or it rolls back. Reaching saga.Complete() is what commits the saga; any exit before it (a fault, or a goto out of the block) runs the compensations.

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…

Step labels (naming a child run so it survives a new version)

Every child workflow you AWAIT carries a label — a literal string you choose, naming that step. Awaiting parks the run…

For(entity).Audit

Reads a running instance's lifecycle timeline — every transition, claim, deposit, reminder and refusal as an…