Why Osy# · Chapter 04 · Durable execution, built in
Your database is transactional. The world is not.
A rollback undoes your rows. It does not undo the payment you took, the email you sent, or the model call you were billed for. So in Osy# every call that leaves the platform runs exactly once, across a crash and across a deploy, and there is no retry policy to write.
- idempotency keys
- a processed_events table
- a retry policy per call site
- a dead-letter queue
- the convention nobody must forget
$ osy model --json # in demo/agent-expenses"name": "ReadReceipt", "effects": ["Llm.Extract", "UnitOfWork.Commit"], "durability": "External", "durabilityVia": "Llm.Extract" // The verdict is not folklore. It is in the resolved // model, with the route it took — the compiler found // the call that leaves the platform, and named it.
Why any of this matters
A rollback undoes your rows. It undoes nothing else.
Not the payment you took, not the email you sent, not the row you wrote in somebody else's system, and not the model call you were billed for. The moment your code reaches outside, a crash leaves you in a state your database cannot describe, and cannot repair.
And it will be interrupted, because to a running process a deploy is indistinguishable from a crash: the process stops mid-work either way, and so does a scale-down and an out-of-memory kill. They happen on a Tuesday afternoon rather than in a disaster. The failures are not abstract either:
- A second charge, two identical emails to the same customer hours apart.
- A refund issued twice, because the retry did not know the first one landed.
- An approval that waited three days and then executed against code that no longer exists.
Everywhere else this is your product's problem: an idempotency key threaded through every handler, a processed_events table, a dead-letter queue somebody has to read, a retry policy per call site, and a convention every person who joins has to be taught and never forget. None of that is your product. It is a tax levied by the gap between your database and the world.
To a running process, every deploy is a crash.Not because the deploy went wrong: the process it replaces stops mid-work either way. So the question is not whether work gets interrupted, but what an interruption costs. Here: nothing that already happened, happens again.
02
The compiler already knows which calls leave the platform
A workflow body can run more than once; that is how a crash is survived. Work that did not commit is done again. Re-running arithmetic is harmless. Re-running a payment is a second charge. So every call is classified, and the ones that leave are wrapped in a durable step, per call, with nothing marked.
A resume finds the first call already recorded, skips it, and picks up at the second. The ordinary code between them re-runs freely, because it is a computation over results that are already written down.
| Durability | What it means | On a resume |
|---|---|---|
| Deterministic | the same inputs give the same answer | re-run, freely |
| Nondeterministic | it reads a clock, a random number, the database | re-run, but the value it produced is recorded where it must not move |
| External | it leaves the platform: a model, an HTTP call, a mail | not re-run. The recorded result is reused |
It is decided per call, not per function. A function that makes two outbound calls is two steps. If the whole function were one step, a crash between them would re-send the first on resume. This is the part that matters, and it is the part a hand-written retry wrapper usually gets wrong.
You wrote no annotation to get this. The classification comes from what the code touches, the same way the compiler decides where a line runs. And because it is in the resolved model, an agent can read it: osy model --json reports the durability of every member and the call it flows through — the receipt at the top of this chapter.
03
When "do not run it twice" is not enough
Some work has to be undone. Stock was reserved, a card was charged, and then the courier hand-off failed; the two committed steps have to come back. That is a saga, and it is written as a block with a lifetime rather than as a chain of callbacks.
workflow FulfillmentSaga { Tracks = Fulfillment.Status; Autostart = true; Initial = Building; state Building { enter { 1 var trace = Workflow.Once("make-trace", () => new Trace { Fulfillment = this.Item, Log = "" }); Log.Information("▶ fulfillment {Reference}: starting saga", this.Item.Reference); 2 await using var saga = Workflow.BeginSaga(); // dispose-without-Complete unwinds the committed steps, in reverse try { Log.Information(" ✓ step 1: reserving stock"); var reservation = Workflow.Once("make-reservation", () => new Reservation { Fulfillment = this.Item }); 3 await saga.Run("reservation", reservation, () => ReleaseStock(reservation, trace)); // step 1 + its undo, coupled Log.Information(" ✓ step 2: charging the card"); var charge = Workflow.Once("make-charge", () => new Charge { Fulfillment = this.Item }); await saga.Run("charge", charge, () => RefundCharge(charge, trace)); // step 2 + its undo 4 saga.OnUnwind(() => ReleaseHold(trace)); // a compensation with NO forward step (a loyalty hold booked inline) Log.Information(" ✓ step 3: dispatching the courier"); var dispatch = Workflow.Once("make-dispatch", () => new Dispatch { Fulfillment = this.Item }); await saga.Run("dispatch", dispatch); // step 3 — no undo; this child FAILS 5 saga.Complete(); // never reached goto Fulfilled; 6 } catch (WorkflowError e) { Log.Information("✗ fulfillment {Reference}: a step failed — rolling back committed steps in reverse", this.Item.Reference); goto Aborted; // the goto unwinds through the await-using dispose → H, C, S } } } terminal success Fulfilled { } terminal error Aborted { Message = "fulfillment aborted — reservation and charge compensated"; }
An explicit memo. Creating this row is not an outbound call, so nothing would have made it a step. Once is how you say "whatever else re-runs, this happened already".
The saga is a scope. Leaving it without calling Complete() unwinds every step that committed, in reverse. await using is the ordinary C# shape, doing the ordinary C# thing.
The undo is coupled to the step that needs it, at the point the step is written. Not in a rollback function at the bottom of the file that has to be kept in step with the forward path.
A compensation with no forward step — something committed inline that still has to come back. It unwinds in the same order as the rest.
The only thing that keeps the work. Reaching the end of the block without it is a rollback, so the failure path is the default and the success path is the one you say out loud.
The failing child raises. The goto leaves the scope, which disposes the saga, which unwinds: refunding the charge, then releasing the stock, in reverse.
What happens when you change it
Durability, and the questions people actually ask about it.
Each of these is a thing you would do on a Tuesday. The verdict is the compiler's, not a convention's.
| You do this | Verdict | Because |
|---|---|---|
| Deploy while a run is parked | compiles | The parked run keeps resolving against the code and data shape it started under. That is what an app version is for. |
| Deploy a change that would strand a parked run | refused | Refused at deploy, and a migration is generated naming what it could not decide for you. |
| Add a retry policy around an outbound call | refused | There is nothing to add it to. The call is already a step; a resume reuses its recorded result rather than repeating it. |
| Reorder two steps in a saga | compiles | The unwind order follows: it is the reverse of what committed, not a list you maintain. |
Forget saga.Complete() | compiles | It rolls back. Compiles, runs, and undoes the work, which is the safe way round for the mistake to land, and why the default is that way. |