Summary#
A workflow body can run more than once — that is how the platform survives a crash: work that did not commit is simply done again. Re-running ordinary computation is harmless. Re-running a payment, an email, or any call into a system that is not yours is a second charge and a second message.
Workflow.Once (run a step at most once) is the explicit way to say "not twice". You rarely need it, because the compiler already knows which calls leave the platform, and wraps each one in a durable step for you. The call you write is the call you read; the durability is not something you remember to add.
Description#
What gets a step#
Exactly one thing: a call that leaves the platform. An outbound client operation is an HTTP request to somebody else's system, so a second execution is a second request. That is the whole rule, and it is decided per call, not per function.
void Notify(Order order) {
Mailer.Send(new SendRequest { To = order.Email }); // a durable step, automatically
}Nothing marks it. Nothing has to.
Each call is its own step#
If a function makes two outbound calls, they are two steps, not one:
void NotifyBoth(Order order) {
Mailer.Send(new SendRequest { To = order.Email }); // step 1
Shipping.Book(new BookRequest { Id = order.Code }); // step 2
}This is the part that matters. If the whole function were one step, a crash between the two calls would re-run the email on resume. Because each call is its own step, a resume finds the email already recorded, skips it, and picks up at the booking. The ordinary code between the two steps re-runs freely — it is just a computation over results that are already recorded.
What does not get a step#
Values that are merely unrepeatable — the current time, a new identifier, a random number — are not steps. Nothing about them leaves the platform, and a resumed run already sees the same value it saw the first time. They cost nothing to protect and are protected anyway.
Ordinary reads, writes and computation are not steps either. Re-running them is correct: the work that did not commit is redone, which is the point.
Outside a workflow#
The same function is often called from a workflow and from an ordinary request. There, there is no run to record against — so the call simply happens, exactly as the source reads. You do not write the function twice, and you do not choose in advance which kind of caller it is for.
When you still write it yourself#
Reach for Workflow.Once (run a step at most once) when you want something automatic durability deliberately does not do:
- An idempotency key. A recorded result means the step is not re-run; it cannot undo a call that already reached
the other system and was lost on the way back. Only that system can recognise a retry, and only from a key you pass
it.
Workflow.Once("step", key => …)hands you a stable one. - Skipping expensive but harmless work. Re-running a long pure computation is correct, just wasteful.
Onceis also how you say "do not redo this". - Your own boundary. Grouping several calls into one step, or pinning one specific result.
Writing Once around a call that would have been lowered anyway gives you one step, not two — yours, with whatever
you asked for.
Examples#
enum OrderState { Placed, Fulfilled }
entity Order {
[Required, MaxLength(60)] string Reference;
decimal Total;
OrderState Status = OrderState.Placed;
security { allow read, create, update when IsAuthenticated; }
}
class ChargeResult { string? Receipt; }
// An ordinary typed client. Nothing about it says "durable".
client Payments {
BaseUrl = "https://api.payments.example";
[Post("/charges")]
ChargeResult Charge([Query] string reference, [Query] decimal amount);
}
// Nothing here is marked either. The call that LEAVES the platform is what makes this a durable
// step — and `osy model --json` reports both the verdict and the route to it, so you never guess:
// "durability": "External", "durabilityVia": "Payments.Charge"
void Fulfil(Order order) {
Payments.Charge(order.Reference, order.Total); // a step: never re-charged on resume
order.Status = OrderState.Fulfilled; // ordinary work — re-runs freely
}An agent step and a charge, with no ceremony at all:
void Fulfil(Order order) {
var decision = Assistant.Decide(order.Summary); // a step: never re-charged, never re-decided on resume
Payments.Charge(new ChargeRequest { Amount = order.Total }); // a separate step
order.Status = Status.Fulfilled; // ordinary work — re-runs freely if the run resumes
}The same call made exactly-once at the other end, by asking for a key:
void Charge(Order order) {
Workflow.Once("charge", key => Payments.Charge(new ChargeRequest { Amount = order.Total, idempotencyKey: key }));
}Notes#
- The guarantee is at-least-once execution, at-most-once result. A crash in the instant after a call returns and before its result is recorded will run it again. No engine can close that window from this side — the call already happened in someone else's system. An idempotency key is the only thing that can, which is why one is offered.
- A recorded step can outlive work that was rolled back. If the surrounding transaction rolls back after the call escaped, the record still says it ran — because it did.
- Records belong to the run. Two runs doing the same work each do it once.
- It composes with app versions. A recorded result is data belonging to the run, so a redeploy does not disturb it — see Deploying while workflows are running.
See also#
- Workflow.Once (run a step at most once) — writing a step yourself, and the idempotency key.
- 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.