Osy#betaRun it on Osyrin: osyrin.com

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 execution of its body, however many times a crash, a retry or a resume makes that execution run again. The first attempt runs the step and records its result durably; every later attempt of the same execution returns the recorded result without running the step again, while the body's NEXT execution (a state entered again, an event arm fired again) is new work and runs it anew. 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 an execution of the body reaches that call site, the step runs and its result is recorded durably. Every later attempt of that execution — a retry after a crash, a resume after a wait — gets the recorded result straight back and the step does not run. A later execution of the body (the state entered again, the arm fired again) is new work, and runs it again — see executions.

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. That includes a value that is different each time it is asked: a DateTime.UtcNow or Guid.NewGuid() read above the retried step is read again on every attempt, and osy lint says so (workflow-value-read-again-on-retry) when that value is then stored — see Current time (DateTime.UtcNow, DurableClock.Now).

A retry asks everything inside the step again

"Retry this step" means the whole step. Each attempt is a new execution for everything inside it — including a step nested in it, and the outbound call a helper makes, which the platform turns into a step of its own (Automatic durability (steps you do not have to write)). So a step whose helper posts to a payment processor and throws on a 503 posts again on the retry, and succeeds on the answer it gets then:

string AskProcessor(Order order) {
  var answer = Http.Post("https://pay.example/charge", order.Reference, "text/plain");
  if (answer.StatusCode != 200) { throw new Exception("the processor answered " + answer.StatusCode); }
  return answer.Body;
}

// …in a workflow body:
this.Item.ReceiptId = Workflow.Once("charge", () => AskProcessor(this.Item),
                                    retry: Backoff.Exponential(TimeSpan.FromSeconds(2)).MaxAttempts(4));

Two things stay exactly as the rest of this page promises:

  • The same attempt delivered again is the same attempt. A crash after the inner call was recorded but before the attempt committed runs that attempt again, and the call it already made is read back, not made twice.
  • A step outside the retried one is untouched. A step that completed before it in the body still replays its record on every attempt.

The operator-visible ids say which attempt a nested step belongs to: OrderFlow/Placed.enter@1/charge~1/AskProcessor#auto:Http.Post is the call AskProcessor made during the first retry of charge, on the first entry to Placed. The first try carries no ~ segment.

⚠ A step whose body makes several outbound calls repeats all of them. If one of them must not be made twice — a charge before a confirmation email — give it its own step outside the retried one, so it is recorded once and replays. osy lint points at a retried step that reaches more than one outbound call (workflow-retried-step-repeats-several-calls).

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 and which execution of it this is, 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@1/Charge#charge-card — the first entry to Approved, through Charge, at the step labelled 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.

Each execution of a body is its own set of steps#

"At most once" means once per execution of the body, not once per run. A run executes one body many times whenever it comes back to it, and every one of those is new work that must reach the outside world again:

  • an event arm that fires twice while the run stays in its state;
  • a state's enter on a second visit — a run that goes Asking → Resting → Asking;
  • a state's clock route (on Expire) when the state is entered again and its clock re-armed;
  • each firing of a repeating reminder (Remind Poll(After = …, ThenEvery = …)).

So each execution is numbered — its visit of that body, counted per body on the run — and the number is part of every step's identity: Asking.enter@1#… on the first entry, Asking.enter@2#… on the second. The same number qualifies the label of a child run the body starts (await Workflow.Run("check", c)), so a second visit starts a second child rather than being handed the first visit's finished one.

What is not a new execution is the one case Once exists for — the same execution delivered again:

  • a body that faulted and is delivered again (an event re-raised after its arm threw, a clock route fired again after it threw): the fault rolled the run back, the visit was never counted, and the redelivery replays the calls the failed attempt already made;
  • a retry: re-entry, and the resume after an await: the park carries its visit, so the steps before the fault or the wait are found, not made again. (The steps inside the retried step are the exception, and on purpose: each attempt of it is new work for them — see what a retry asks again.)

A visit is counted only once its execution is finished with — completed, parked, or (for a reminder, whose clock re-arms whatever happened) faulted — and it is committed with that execution's own work, so a crash anywhere before the commit leaves the count where it was.

A run that was already parked when this numbering arrived keeps the identities it recorded under: its park carries no visit, so it resumes under ids with none, finds the steps it completed, and every execution after it is numbered.

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.

Two calls in EXCLUSIVE branches are one call#

The rule above counts calls that can both run. Two arms of one if / else if / else, or two sections of one switch, never both run — so a helper called from each arm is not a collision and is not refused:

if (this.Item.Severity >= 3)      { NotifyOne(this.Item.Reporter, "critical"); }
else if (this.Item.Severity == 2) { NotifyOne(this.Item.Reporter, "major"); }
else                              { NotifyOne(this.Item.Reporter, "minor"); }

They share one step identity, and that is safe rather than tolerated: a memo belongs to one run, and in any one run exactly one arm executes, records under that identity, and is the arm that reads it back when the run resumes.

⚠ What is still refused is a call that can run ALONGSIDE another — one inside an arm and one after the whole if, or two in the same arm. Both really do execute on the same path, so the second would read back the first's record:

if (this.Item.Severity >= 3) { NotifyOne(this.Item.Reporter, "critical"); }
NotifyOne(this.Item.Reporter, "always");     // ✗ called twice — the refusal names the other line

try / catch is deliberately not treated as a choice: a body can run partway and then its catch runs too, in the same pass.

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:

using Osysharp.Workflow;

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…