Summary#
await saga.Run("ship", shipment) and await Workflow.Run("fulfil", order) park the run: it stops, durably,
until the child finishes. A three-step approval saga can sit parked for a week — and a week is long enough for the app
to be deployed again.
When that happens, the new version has to answer one question about the parked run: where is it, and what has it already done? "Step 2 of 5" is only an answer if both versions agree on what "step 2" is. The label is that agreement, and it is why every step carries one.
Signature#
await Workflow.Run("fulfil", order); // label, then the entity
Workflow.Run(order); // fire-and-forget — never parks, so no label
await saga.Run("reserve", hotel); // label, then the step
await saga.Run("reserve", hotel, () => Cancel(hotel)); // …and its compensation, as usualThe label is a literal string. It cannot be a variable or an expression: its whole job is to be the same string in a later version of the body, and a value computed while the run executes could differ between the two readings that have to match.
Description#
Why it is required rather than inferred#
The obvious convenience is to derive a label when you leave it out — from the child workflow's name, or from the step
variable. Both are rejected, and the reason is worth stating plainly: a derived name is not one you chose. Rename
the child workflow, or rename a local from hotel to outbound, and the derived label changes — silently, in a way
that has nothing to do with the rename, and that breaks the migration of every run currently parked at that step.
The cost of the alternative is one string literal per step. You are not writing thousands of workflows an hour, and what the literal buys is that every step is migratable by construction — there is no such thing as a run parked somewhere a new version cannot find.
Two steps may not share a label#
A label is an identity, and two things under one identity is not an identity:
await saga.Run("leg", outbound);
await saga.Run("leg", inbound); // compile errorworkflow 'BookingSaga': two steps here are both labelled "leg", so a resumed run could not tell which of them it had already finished. Give them different labels.
Uniqueness is checked across the whole workflow — its start body, every state's enter body, and every route —
because a run can be parked at any of them.
Why that error is at compile time#
This explains why you are asked now rather than never.
Two indistinguishable steps are only a problem when a parked run meets a new version — at deploy time, possibly weeks later. Reporting it then would be useless: the run parked with the duplicate already in place, so relabelling afterwards cannot help that run. It would be a complaint nobody could act on, repeating on every deploy for as long as the run lived.
Asked at compile time it is the opposite: you have not deployed, no run exists, and typing two names fixes it permanently.
Examples#
A booking saga with two legs of the same kind — an outbound flight and a return. Both run FlightFlow, so the
labels are the only thing distinguishing them, and they are what let a run parked on the return leg still be
recognised as "outbound done, return in progress" after a redeploy.
enum BookStatus { Start, Booked, Failed }
enum LegStatus { Waiting, Done, Bad }
entity Booking {
[Required, MaxLength(20)] string Ref;
BookStatus Status = BookStatus.Start;
}
entity FlightLeg {
[Required] Booking Booking;
[Required, MaxLength(10)] string Direction;
LegStatus Status = LegStatus.Waiting;
bool Cancelled;
}
// The compensation. `Status` belongs to FlightFlow (it is what the workflow `Tracks`), so app code cannot assign it —
// a compensation records its own outcome on a field it owns.
void CancelFlight(FlightLeg leg) { leg.Cancelled = true; }
workflow FlightFlow {
Tracks = FlightLeg.Status; Autostart = false; Initial = Waiting;
event Finish();
state Waiting { subscribe Finish(); on Finish { goto Done; } }
terminal success Done { }
terminal error Bad { Message = "the leg failed"; }
}workflow BookingSaga {
Tracks = Booking.Status; Autostart = false; Initial = Start;
state Start {
enter {
var saga = Workflow.BeginSaga();
try {
var outbound = Workflow.Once("make-outbound", () => new FlightLeg { Booking = this.Item, Direction = "out" });
await saga.Run("outbound-flight", outbound, () => CancelFlight(outbound));
var inbound = Workflow.Once("make-inbound", () => new FlightLeg { Booking = this.Item, Direction = "back" });
await saga.Run("return-flight", inbound, () => CancelFlight(inbound));
saga.Complete();
goto Booked;
}
catch (WorkflowError) { goto Failed; }
finally { await saga.DisposeUnwind(); }
}
}
terminal success Booked { }
terminal error Failed { Message = "the booking failed"; }
}Notes#
- A label names a step in the body, not a child run. Two different runs of the same workflow each have their own step at that label; the label distinguishes places in the code, not instances.
- Labels are compared exactly, case included.
- A fire-and-forget
Workflow.Run(order)— one written withoutawait— takes no label. Awaiting is what makes a start a park point, and only a park point has an identity a later version must match; a start you do not wait on has nothing to re-find.saga.Runalways takes one, because every leg is joined. - The same reasoning, for a different mechanism, gives Workflow.Once (run a step at most once) its step label. A
Workflow.Oncelabel keeps an idempotency key stable across a deploy; a step label keeps a position recognisable across one.
See also#
- Workflows that outlive the code that started them — why a park point needs a name at all: what a deploy does to a run sitting at one
- Workflow.BeginSaga (a compensating saga scope) — the saga scope these steps run in
- Workflow.Run (start a workflow) — starting a child workflow, awaited or not
- Workflow.Once (run a step at most once) — the other label, and the other kind of durability
- Migrating runs that are still in flight — what a deploy does to runs that are already parked