Why Osy# · Chapter 03 · Workflows
A process that waits three days for a person is still just source.
An order is placed, paid, shipped and delivered by different people over days, and between those steps the process waits. After a deploy or a restart it must still mean the same thing. In Osy# that process is a workflow declared beside the entity it is about: its state is a column on the entity, each state says which events it accepts and who may raise them, a wait is a row that says who is asked and for how long, and a run outlives the deploy that started it.
demo/wf-order-saga/model/order.osy · abridgedworkflow OrderLifecycle { // the state is a column on the entity Tracks = Order.Status; Autostart = true; Initial = Placed; event Pay(); event Ship(string tracking); event Deliver(); [Authorize(u => RoleGrant.Any(g => g.Grantee == u && g.Level == AppRole.Manager))] event Cancel(); state Placed { subscribe Pay(); on Pay { goto Paid; } } state Paid { // a wait, offered to whoever holds the role below subscribe Ship(string tracking) as Fulfil { Candidates = u => RoleGrant.Any(g => g.Grantee == u && g.Level == AppRole.Fulfillment); } on Fulfil(string tracking) { this.Item.Tracking = tracking; goto Shipped; } } state Shipped { subscribe Deliver(); on Deliver { goto Delivered; } } … terminal success Delivered { } terminal cancel Cancelled { Message = "order cancelled"; } }
01
The state is a column on your entity
A workflow tracks a field you already declared. Here Order.Status is both the order's stage and the workflow's state, so the lifecycle is declared beside the data it moves, and each part of the declaration answers one question: what may happen, who may make it happen, and what happens next.
workflow OrderLifecycle { 1 Tracks = Order.Status; 2 Autostart = true; Initial = Placed; 3 event Pay(); event Ship(string tracking); event Deliver(); 4 [Authorize(u => RoleGrant.Any(g => g.Grantee == u && g.Level == AppRole.Manager))] event Cancel();
The workflow's state IS Order.Status. A screen that lists orders by stage is an ordinary query over that enum column, and the stage is stored exactly once.
Creating the entity starts its workflow. A run begins when an Order row is created, so every order has a run from its first moment.
The vocabulary of what can happen. An event may carry arguments, Ship(string tracking), and those arguments are what the person doing the work is asked for.
Who may raise it, declared on the event itself. The page that renders the available actions derives them from this same declaration, so an event a caller may not raise is one they are never offered.
Then the states. Each one says which events it accepts, and a handler moves the order on:
1 state Placed { subscribe Pay(); 2 on Pay { goto Paid; } } state Paid { 3 subscribe Ship(string tracking) as Fulfil { 4 Candidates = u => RoleGrant.Any(g => g.Grantee == u && g.Level == AppRole.Fulfillment); } 5 on Fulfil(string tracking) { this.Item.Tracking = tracking; goto Shipped; } } state Shipped { subscribe Deliver(); on Deliver { goto Delivered; } } terminal success Delivered { } terminal cancel Cancelled { Message = "order cancelled"; } 6 terminal cancel Returning { Message = "order cancelled after shipping — return + refund issued"; }
A state accepts the events listed in it. While the order is Placed, Pay is the one thing that can happen to it.
goto moves the tracked state. It is the only thing that does, so the sequence of stages an order went through is complete by construction.
A slot: work offered to a set of people. This is the difference between a state machine and a workflow. The state says the order is paid; the slot says somebody now has to ship it, and the slot is a row you can list, assign, chase and measure.
Who the work is offered to, as a predicate over the application model. An inbox is a query over that same model for the work open to a person.
The slot's handler receives what the person supplied, records it on the order, and moves the state.
Terminals are typed. success, cancel and error are three different endings, so a report that wants "how many failed" reads the ending rather than inferring it from a status name.
02
Cancel from anywhere, and compensate
An event handler declared outside every state applies in all of them. on Cancel is one: a manager may cancel an order at any stage. What cancelling means depends on where the order is, because an order already shipped cannot be undone by changing a column; the goods have to come back.
on Cancel { 1 if (this.Item.Status == OrderStatus.Shipped) { 2 var ret = Workflow.Once("make-return", () => new ReturnShipment { Order = this.Item }); 3 await Workflow.Run("ret", ret); // hold until the return workflow refunds goto Returning; } else { goto Cancelled; } }
The handler reads the order's stage. Before shipping, cancelling is a transition to Cancelled and nothing more.
A return is its own workflow, started here. Workflow.Once creates the return shipment exactly once under a name, so a run that resumes after a crash finds the one it already made.
The order waits for the return. The child workflow runs to one of its terminals, and only then does the order move to Returning. Workflows compose this way: a longer process is one workflow waiting on another.
03
Time is part of the state
A wait is not open-ended. How long it may take, whose time it is, who is reminded and when, and what silence means are declared on the wait itself, and the run keeps them — across a restart and a deploy.
Here a ticket waiting on its customer reminds them after two days and every two days after that, and a week of silence solves it:
state Pending { enter { Arrive(this.Item, Stage.Pending, DurableClock.UtcNow); WaitOn(this.Item, WaitFor.Requester, DurableClock.UtcNow); } subscribe Resume() as Unpend { Finished { 1 Within = TimeSpan.FromDays(7); 2 Accrues = false; 3 Remind Chase(After = TimeSpan.FromDays(2), ThenEvery = TimeSpan.FromDays(2), Accrues = false) { this.Item.ChasesSent = this.Item.ChasesSent + 1; TicketLine(this.Item, "helpdesk.chased", new TicketFacts()); } 4 Unfinished { TicketLine(this.Item, "helpdesk.auto-solved", new TicketFacts { Number = 7 }); goto Solved; } } } subscribe Reply() as PendingReply; subscribe Hold(string waitingOn) as PendingHold; subscribe Solve(string summary) as PendingSolve; on Unpend { ResumeWork(this.Item, DurableClock.UtcNow); goto Open; } on PendingReply { } on PendingHold(string waitingOn) { this.Item.WaitingOn = waitingOn; goto OnHold; } on PendingSolve(string summary) { this.Item.Resolution = summary; goto Solved; } }
How long the wait may take. A slot with a Finished block has a window, and the window is what a board shows as time left.
Whose time it is. This wait is the customer's, so it does not count against the desk. The desk's own promises — first reply, resolution — are kept by the inbox the ticket is in, and they pause while the ticket waits here.
A reminder is declared, not scheduled. It fires while the wait is still open, on the run's own clock, and the body is ordinary code over the ticket.
What silence means. A wait that runs out has a declared outcome, so a ticket nobody answers does not sit open forever.
What happens when you change it
Workflow changes, and what they cost
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 |
|---|---|---|
| Add a state | compiles | Additive. Existing runs are in the states they were in; new ones can reach the new one. |
| Remove a state that runs are parked in | refused | Refused at deploy without a migration. Stranding live work is the failure this exists to prevent, so it is caught when you deploy rather than when somebody opens the order. |
| Rename the tracked enum member | refused | Refused without a migration, and the migration is generated for you — the stored value is what every parked run resolves against. |
| Add an argument to an event | compiles | The people who satisfy that slot are asked for one more thing. A one-click move is no longer offered for it, and the UI knows that without being told. |
Tighten Candidates | compiles | The inbox narrows. It is a predicate over your model, so it narrows the same way any other query would. |