Osy#betaRun it on Osyrin: osyrin.com

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, and a running process is interrupted by a deploy as surely as by a crash. In Osy# every call that leaves the platform is a durable step: the compiler finds the call, the runtime records what it returned as it completes, and a resume after a crash or a deploy reuses the record instead of making the call again. The record is committed the instant the call returns, so the only window left is that instant; a call that must not land twice even then asks for a key, and the receiver is handed an idempotency key derived from the step.

from reference/workflow/automatic-durability · abridgedpublic class Payments {
  …
  public ChargeResult Charge(string reference, decimal amount) {
    var r = Http.Post(BaseUrl + "/charges?reference=" + …,
                      "{}", "application/json");
    …
  }
}

void Fulfil(Order order) {
  new Payments().Charge(order.Reference, order.Total);   // a step: never re-charged on resume
  order.Status = OrderState.Fulfilled;             // ordinary work — re-runs freely
}
$ osy model --json   # that page's app · abridged"name": "Fulfil",
"transitive": {
  "effects": ["Http.Post"],
  "calls": ["Payments.Charge(string,decimal)"],
  …
  "durability": "External",
  "durabilityVia":
    "Payments.Charge(string,decimal) → Http.Post"
}

// The verdict is in the resolved model, with the route
// it took: the compiler found the call that leaves the
// platform, and named it.
In practiceA crash after the charge is recorded and before the confirmation is sent does not charge again on resume: the recorded result is replayed, not the call.

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.

Each of those is one fact that has to stay true across a call, an interruption and a restart: this happened once. In Osy# that fact is the runtime's to keep. The compiler knows which calls leave the platform, and every one of them becomes a step whose result is recorded as it completes; a resume reads the record instead of repeating the call.

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. Follow one run through a crash, with two calls that leave the platform in it.

First run

Payments.Charge(order.Reference, order.Total)

the charge happens

the result is recorded

Mailer.Send(…)

the mail goes out

the result is recorded

crash

the process stops here, between the two calls

Resume

Payments.Charge(order.Reference, order.Total)

a recorded result already exists

it is returned

the charge does not happen again

Mailer.Send(…)

no record yet: the mail goes out, and is recorded

The source runs through the call again. The effect does not happen again.

That is the whole behaviour, and it holds because every call is classified, and the ones that leave the platform 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.

DurabilityWhat it meansOn a resume
Deterministicthe same inputs give the same answerre-run, freely
Nondeterministicit reads a clock, a random number, the databasere-run, but the value it produced is recorded where it must not move
Externalit leaves the platform: a model, an HTTP call, a mailnot 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.

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.

Forward

Reserve stock ✓ committed

↓

Charge the card ✓ committed

↓

Dispatch the courier ✕ fails

Then, in reverse

Refund the charge ← compensates step 2

↓

Release the stock ← compensates step 1

A saga couples each committed step with the work that compensates it. If the saga does not complete, the compensations run in reverse order.

That is what the source below expresses. Each saga.Run carries its own undo; Complete() is the one way to keep the work; leaving the block any other way unwinds.

demo/wf-order-saga/model/order.osyverbatim — this file compiles
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"; }
1

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".

2

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.

3

The undo is coupled to the step that needs it, at the point the step is written, so the forward path and its compensation are read together and change together.

4

A compensation with no forward step — something committed inline that still has to come back. It unwinds in the same order as the rest.

5

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.

6

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 thisVerdictBecause
Deploy while a run is parkedcompilesThe 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 runrefusedRefused at deploy, and a migration is generated naming what it could not decide for you.
Give a step a retry policycompilesretry: Backoff.Exponential(…) on the step. A retry is for a step that did not succeed; a step that already recorded its result is not attempted again, whatever the policy says.
Reorder two steps in a sagacompilesThe unwind order follows: it is the reverse of what committed, not a list you maintain.
Forget saga.Complete()compilesIt 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.