Summary#
A workflow body can run more than once. That is not a bug — it is how the platform survives a crash: work that did not commit is simply done again. For ordinary computation that is exactly right. For a payment, an email, or any call into a system that is not yours, it is a second charge and a second message.
Workflow.Once("step", () => step) marks the boundary. The first time a run reaches that call site, the step runs and
its result is recorded durably. Every later time — a retry after a crash, a resume after a wait — the recorded result
comes straight back and the step does not run.
Signature#
Workflow.Once("charge", () => step) // this step's LABEL, then the step: run at most once
Workflow.Once("charge", key => step) // …and receive an idempotency key to pass on
Workflow.Once("charge", order.Id, key => step) // …with that key scoped to the order, not to this run
Workflow.Once("charge", () => step, // …and try again if it FAILS, on a growing wait
retry: Backoff.Exponential(TimeSpan.FromSeconds(2)).MaxAttempts(4))The argument is a lambda, and that is load-bearing: the step must not run until Once has checked whether it
already did. Writing Workflow.Once("charge", Charge(total)) would evaluate Charge first — the very thing being prevented — so
it is a compile error.
The result type is the lambda's own, so Once can be wrapped around an existing expression without changing anything
around it.
Description#
What it guarantees, precisely#
- The result is recorded once and reused. A re-execution returns the first result, so everything downstream sees the same value it saw the first time and cannot diverge.
- A step that returned nothing still counts as run. A recorded empty result is a hit, not a miss — a notification that returns nothing does not send twice.
- The record survives a rollback. It is committed separately from the surrounding work, on purpose: if it were written with the rest of the step's transaction, a crash before that transaction committed would take the record down with it and the retry would run the step again.
What it does not guarantee#
A crash in the instant between the step returning and its result being recorded will run the step again. That window is small, but it is real, and no workflow engine can close it — the effect happened in someone else's system, and there is no way to know it happened without a record of it. Every durable engine draws the line in the same place.
Closing it takes the other side's help, in the form of an idempotency key it can use to recognise a retry. Take one by giving the lambda a parameter:
var receipt = Workflow.Once("charge", key => Payment.Charge(total, idempotencyKey: key));The key is derived for you, and it is the same key every time this step of this run is attempted — which is exactly what makes the receiving system able to spot the second attempt. It is unique per step and per run, so a loop over three lines presents three keys and a second order presents different ones again. It is opaque: it carries nothing about your code.
Once gives you a stable result; the key gives you a single effect.
retry: — trying again when the step FAILS#
Once is about a step not repeating. retry: is about a step that did not succeed at all. They are opposite
halves of the same question and they compose: a retried step still records its result the first time it works, and
still returns that record for ever after.
Workflow.Once("charge", () => Payment.Charge(total),
retry: Backoff.Exponential(TimeSpan.FromSeconds(2)).MaxAttempts(4))Without retry:, a step that throws ends the body — the fault travels out to whoever drove the run (the person who
clicked the button, the timer that fired) and the run stays where it is. With it, the run parks on a durable clock
and runs the body again when the wait elapses, up to the policy's .MaxAttempts.
The wait is durable rather than a pause, and that is not an implementation detail you can ignore: the body runs inside whatever request drove it, so a five-minute backoff taken in-process would hold a person's browser open for five minutes. Parking means the caller returns immediately and the run resumes on its own.
.MaxAttempts(n) is required here, unlike on a milestone — a milestone has Retries = N counting for it, and a
step has nothing else in scope, so an uncapped policy would retry for ever:
a step's `retry:` policy must cap its attempts with `.MaxAttempts(n)` — without one the step would be retried
for ever. A milestone can leave it out because its `Retries = N` supplies the budget; a step has nothing else in
scope. Write `Backoff.Exponential(TimeSpan.FromSeconds(2)).MaxAttempts(4)` — that is 4 attempts in TOTAL, so 3
retries after the first try.What a retry re-runs, and what it does not
The retry re-enters the body from the top, rather than resuming at the step. That sounds like more work than
necessary and is the only shape that is correct: a step that failed halfway may have written rows, and resuming at
the step would have to commit them. So the failed attempt's writes are discarded entirely, the body runs again, and
every step that already completed returns its record instead of running — which is exactly what Once is for.
Work between the steps is re-executed by the same code that did it the first time.
⚠ So retry: and Once are not independent decorations. A retried body that does effectful work outside a step
will do that work again on every attempt. Put anything that must not repeat inside its own Once.
Which failures are retried
Everything except the ones that cannot succeed on a second attempt: a row that was not found, a value that failed validation, a conflict, an authorization refusal, an unmet requirement, and a cancelled child workflow. Those are the platform having decided something, and waiting changes none of them.
There is no predicate to write. Classifying exceptions at the call site would be a second way to spell catch, in a
position where you cannot see the body it guards — so retry: stays a single value, and anything more specific is
written with the try/catch you would already reach for.
A retried step may not await
A retry parks on a clock and re-runs the body from the top; an await parks on a cursor and resumes where it
left off. One run cannot hold both waits, so a step carrying retry: whose body awaits is a compile error rather
than a policy that quietly stops applying the moment the body suspends. Let the child workflow carry its own retry,
or move the awaited call out of the step.
try/catch sees the LAST failure, not each one
A catch around a retried step is asking what to do when this has failed for good. It does not run per attempt: the
retry happens inside the step, and only when the attempts are spent does the original exception come out — at which
point your handler sees exactly what it would have seen had no policy been written.
When the retry is a whole new run#
The key above is scoped to this run, which is right for "this run's step is being attempted again". It is not what you want when the second attempt is a different run against the same thing — a failed order retried tomorrow, a re-submitted invoice. Those are two runs, so they derive two keys, and the charge happens twice.
Say what the work is really identified by, as an ordinary first argument, and name the step:
Workflow.Once("charge", order.Id, key => Payment.Charge(order.Total, idempotencyKey: key));Now any run reaching that step for that order presents the same key, and the receiving system recognises the second one. Two things stay true and are worth being precise about:
- The step still runs. Scoping changes the key, not the record — a record belongs to a run and is cleaned up with it. What you gain is that the far side refuses to act twice, which is the only place a single effect can be decided.
- Different steps under one scope still get different keys. Otherwise a refund under the same order would present the charge's key and be swallowed as a duplicate — a payment that never happens, which is worse than one that happens twice, because nothing reports it.
Why the scoped form makes you name the step#
The name is not decoration, and it is the reason the two bullets above can both be true. Something has to tell the
"charge" step apart from the "refund" step under the same order, or they derive one key and the refund is
swallowed.
That job used to be done by where the step sat in your code — which worked until you edited the function around it. Then the key changed, and a run started after the edit was no longer recognised as a retry of one started before: exactly the case a scope exists for, since two runs far enough apart to matter will usually straddle a deploy. A name you chose does the same job and survives the edit, so the promise holds across versions. That is why the scoped form requires one and the plain form has no use for it.
Pick names that describe the work — "charge", "send-invoice" — and treat them as durable: renaming one is a new
key, and a run mid-flight against the old name will not recognise the new one.
Where it can go#
Anywhere in a workflow body: a state's enter, an event arm, a timer body, a saga step, or a function the body calls.
Inside any loop — foreach, while, for — each iteration is its own step, so a loop that charges once per line
charges once per line, and a retry re-charges none of the lines it already did.
A step is identified by the path taken to reach it, not only by the line it sits on — the body the run entered,
then the name of each function called along the way, then the step's own label. An operator reading a run's records
sees exactly that: Approved.enter/Charge#charge-card.
Every part of it is a name you chose, so editing the code around a call does not move it. That is what lets a run that started before a deploy recognise the work it already did. The one exception is a loop, which contributes the iteration number — there is nothing in your source to name there, and "which pass over the data" is the honest identity.
Because a path segment is a name, calling one helper twice from the same body would give the steps inside it one identity, so that is a compile error. Loop over the things you are acting on and each pass is its own step; or, if the two calls are really different work, give them their own functions.
(That path identifies a step's record. A scoped step's outward idempotency key is identified by its label instead — see above.)
Every step carries a label#
The first argument is the step's label — a literal string you choose, naming what this step does. It is required.
The label is the step's durable identity. The platform records "this step already ran" against it, so the label is how a later execution finds that record. It used to be derived from where the step sat in the code, which worked within one deployed version and broke across a new one: edit anything above the step and its identity moved, the record was no longer found, and the step ran a second time. For a payment or a message, a second run is the whole problem this construct exists to prevent.
A name you chose does not move when you edit the code around it. That is the entire reason it is not optional.
Workflow.Once("charge-card", key => Payments.Charge(total, idempotencyKey: key));
Workflow.Once("send-receipt", () => Email.Receipt(order));Two steps in the same body may not share a label — they would share one identity, and the second would read back the first's recorded result and never run. That is a compile error.
A body here means one block the platform runs as a unit: a function, or one of a workflow's own — its Start, a
state's enter, a single event arm, a milestone hook, a reminder. Each is a separate identity, so the same label in
a state's enter and in an event arm is two different steps, and is fine. So is the same label in two different
functions. Labels are compared exactly.
You do not have to write one — until two calls collide#
Most external calls need no Once at all. Write the call plainly and the platform wraps it in a step for you, naming
that step after the call:
Mailer.Send(receipt); // one step, named for `Mailer.Send`. Nothing to write, nothing to maintain.That name is a complete identity while there is one such call in the body — nothing about Mailer.Send moves,
however much you edit around it. Two calls to the same operation in one body have no way to be told apart, so the
compiler asks you to name them:
Mailer.Send(primary); // ✗ two unlabelled steps, both named for `Mailer.Send`
Mailer.Send(backup);two external steps here both go through
Mailer.Send, so neither carries an identity that survives an edit … Name them:Workflow.Once("a-name-for-this-one", () => Mailer.Send(…))
Name either one and the ambiguity is gone — the labelled step takes your name and the remaining plain call keeps the derived one. Calls to different operations never collide, so they need nothing.
Why it is refused rather than guessed at. Numbering them by position works right up to the edit that matters: insert an earlier call to the same operation and the later one renumbers, so a run resuming after that deploy no longer recognises the step it already did — and sends twice. That is the failure this whole construct exists to prevent, so the platform declines to guess.
Examples#
Charging a card — the case the construct exists for:
state Approved {
enter {
var receipt = Workflow.Once("charge", () => Payment.Charge(this.Item.Total));
this.Item.ReceiptId = receipt.Id;
goto Shipped;
}
}If the process dies after the charge but before goto Shipped commits, the run retries enter from the top — and the
charge does not happen again.
One step per item — and the same in any loop:
foreach (var line in this.Item.Lines) {
Workflow.Once("reserve", () => Fulfilment.Reserve(line.Sku, line.Quantity));
}Each line is its own step: a retry re-reserves none of the lines already reserved, and still reserves the rest.
A step that has to survive a flaky dependency — the whole workflow, compiled:
enum OrderStage { Placed, Charged, Failed }
[Principal] entity Person {
[Required, MaxLength(80)] string Name;
security { allow read, create when IsAuthenticated; }
}
entity Order {
[Required, MaxLength(120)] string Reference;
OrderStage Stage;
decimal Total;
[MaxLength(200)] string ReceiptId;
security { allow read, create, update when IsAuthenticated; }
}
// Stands in for the payment gateway. Anything that can be briefly unavailable belongs behind a step.
string Charge(Order order) {
return "receipt-" + order.Reference;
}
workflow OrderFlow {
Tracks = Order.Stage;
Autostart = true;
Initial = Placed;
event Pay();
state Placed {
subscribe Pay();
on Pay {
// Four attempts in total — 2s, then 4s, then 8s — and never twice on success, because it is still a step.
this.Item.ReceiptId = Workflow.Once("charge", () => Charge(this.Item),
retry: Backoff.Exponential(TimeSpan.FromSeconds(2)).MaxAttempts(4));
goto Charged;
}
}
terminal success Charged { Message = "paid"; }
terminal error Failed { Message = "could not charge"; }
}Skipping expensive work that is merely slow:
var report = Workflow.Once("build-quarterly-report", () => BuildQuarterlyReport(this.Item));Nothing here leaves the platform, so re-running would be correct — just wasteful. Once is also how you say "do not
redo this."
Notes#
A recorded step can outlive work that was rolled back. If the surrounding transaction rolls back after the step escaped, the record still says it ran — because it did. Surprising once; correct every time.
Onceis about repetition, not waiting. It does not pause the run and is not a substitute for Workflow.Run (start a workflow)'s durable wait. A step that itself waits can be wrapped in one.Records belong to the run and are cleaned up with it. That stays true even with a scope: what a scope shares across runs is the KEY, never the record — so two runs doing the same work each still do it once, and it is the receiving system that declines the second.
It composes with app versions. A recorded result is data belonging to the run, so it is unaffected by a redeploy — see Deploying while workflows are running.
You often do not need to write it. A call that leaves the platform is made a step by the compiler — see Automatic durability (steps you do not have to write). Writing
Oncearound one of those gives you one step, not two. Reach for the explicit form when you want an idempotency key, your own boundary, or to skip expensive-but-harmless work.
See also#
- Backoff (retry policy) — the retry policy
retry:takes, and what.MaxAttempts/.Cap/.Jittermean - Workflows that outlive the code that started them — how a step's label, its memo and its call path survive a deploy
- Automatic durability (steps you do not have to write) — the steps the compiler writes for you, and when to write your own.
- Workflow.Run (start a workflow) — start a workflow, and optionally wait for it.
- Workflow.BeginSaga (a compensating saga scope) — compensating steps, for work that must be undone rather than not repeated.
- Deploying while workflows are running — what a run in flight keeps executing across a deploy.