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

Reference / Workflow

Workflow.Once (run a step at most once)

Workflow.Once("label", () => step) · Workflow.Once("label", key => step) · Workflow.Once("label", scope, key => step) · Workflow.Once("label", () => step, retry: <Backoff>)

Run something at most once per workflow run, however many times the surrounding code re-executes. The first execution runs the step and records its result durably; every later execution of that call site returns the recorded result without running the step again. Reach for it around anything that leaves the platform — a payment, a message, an outbound call — where running twice would be worse than running slowly.

stable1 example compiled by CIworkflowauthoringdurability

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.

  • Once is 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 Once around 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#

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(…))`…

Backoff (retry policy)

A retry policy as a value: how long to wait before the next attempt. Three shapes say how the wait GROWS — `Fixed` (the…

Deploying while workflows are running

A workflow run can outlive the deploy that started it. Deploying with --new-version freezes the code and data shape the…