Summary#
A state can run code at two moments: when a run arrives in it, and when a run leaves it.
state UnderReview {
enter { this.Item.ReviewStartedAt = DateTime.UtcNow; }
exit { this.Item.ReviewMinutes = (DateTime.UtcNow - this.Item.ReviewStartedAt).TotalMinutes; }
}Both are optional, and both run inside the same transaction as the move that triggered them — so if either throws, the move does not happen at all.
Signature#
state <Name> {
enter { <statements> } // on arrival — MAY `goto` to redirect the run
exit { <statements> } // on departure — may NOT `goto`
}Description#
Can enter/exit redirect with goto?#
enter may redirect: enter { if (order.Total > 10000) goto NeedsApproval; } sends the run somewhere else instead of
settling in this state. That works because on arrival, where the run ends up is still the thing being decided.
exit may not, and a goto in one is a compile error. By the time an exit body runs the transition has already
been decided — something chose the destination, and this body is running because of that choice. A redirect here
would either silently override a decision already made, or, if two states' exit bodies each redirected into the
other, bounce between them for ever with no arm to break the cycle.
The refusal names where the decision does belong:
an `exit { }` body may not `goto` — state 'Open' runs its exit body BECAUSE a transition was already decided, so a
`goto` here would override a choice already made (and two states redirecting to each other would never settle). Put
the decision where it is still open: an `on` arm, a `complete when`, or the destination state's own `enter { }`,
which MAY redirect.It reads the same way as the rule for milestones, which likewise may not goto from their enter: a body that runs
as a consequence of a decision does not get to re-make it.
Can goto choose between two states?#
Yes: goto <condition> ? <State> : <State>; goes to one or the other, and reads exactly as
if (<condition>) { goto A; } else { goto B; }. A branch may be a choice itself (a ? X : b ? Y : Z). It is legal
wherever goto is — an on arm, an enter, a breach body.
using Osysharp.Workflow;
enum ParcelStatus { Dropped, Weighed, NeedsFreight, Ready, Sent }
entity Parcel {
int Grams;
ParcelStatus Status = ParcelStatus.Dropped;
security { allow read, create, update when IsAuthenticated || IsAnonymous; }
}
workflow ParcelFlow {
Tracks = Parcel.Status; Autostart = false; Initial = Dropped;
event FreightBooked();
state Dropped { enter { goto Weighed; } }
state Weighed { enter { goto this.Item.Grams > 1000 ? NeedsFreight : Ready; } }
state NeedsFreight { on FreightBooked { goto Sent; } } // waits for a person to book it
state Ready { enter { goto Sent; } }
terminal success Sent { }
}
string PathOf(Parcel parcel) {
var path = "";
foreach (var e in ParcelFlow.For(parcel).Audit) { if (e.From != e.To) { path = path + e.From + ">" + e.To + " "; } }
return path;
}
[Test]
void a_light_parcel_goes_straight_through_and_its_trail_reads_in_the_order_it_happened() {
TestClock.Set(new DateTime(2031, 5, 5, 8, 0, 0)); // every move below is made in this one instant
var parcel = new Parcel { Grams = 200 };
Workflow.Run(parcel);
Workflow.Settle(parcel);
Assert.Equal(ParcelStatus.Sent, parcel.Status);
Assert.Equal("Dropped>Weighed Weighed>Ready Ready>Sent ", PathOf(parcel));
}
[Test]
void a_heavy_one_stops_for_freight() {
var parcel = new Parcel { Grams = 5000 };
Workflow.Run(parcel);
Workflow.Settle(parcel);
Assert.Equal(ParcelStatus.NeedsFreight, parcel.Status);
Assert.Equal("Dropped>Weighed Weighed>NeedsFreight ", PathOf(parcel));
}Anything that is not a state's name or a choice between states is refused where it is written: goto takes the
name of a state — goto Approved; — or a choice between two.
May an exit end other work?#
Yes. An exit body runs while its own run is being moved, and it may call code that drives another run there and
then — calling off the work this run was waiting for, say. If that other run's own code raises an event back on
THIS run (work that ends lets go of whoever waits for it), the event is kept until this run's step is over and
delivered then, exactly as an event a body raises on its own run is: by then the run is where the transition took
it, and an event that no longer applies is recorded on its trail as not delivered.
In what order do exit, enter and the deadline run?#
Leaving A for B runs, in one transaction:
A'sexit { }B'senter { }B's waits and deadline are armed
So an exit body sees the world as it was in the state it is leaving, before anything about the destination applies.
Is the clock still running in an exit body?#
A state's SLA clock has already stopped by the time its exit body runs. Accrues measures time spent in the
state, and work done on the way out is not that — so an exit body can never inflate the very measurement it is often
there to record.
What happens if a body reaches the network?#
Wrap it. A body here is not an ordinary function: nobody called it, so there is nobody to hand a failure to.
A timeout, a DNS failure or a refused connection is not a workflow outcome — the platform treats it as worth another attempt, so the delivery is retried and this whole body runs again from the top, with the row still sitting in the state it was arriving in. That is right for a blip and wrong for an endpoint that is simply gone, and either way it is not what you would have chosen if you had been asked.
osy lint asks for you, in every body a workflow declares — enter, exit, an on route, a milestone arm, a
Remind. Three findings cover the two ways a call can go wrong, and they cover the reach through a helper as well
as the call written here, because a body that says only SendMail(this.Item) warns you of nothing in its own text:
| finding | what it saw |
|---|---|
reliability-outbound-call-unguarded | an outbound call written in this body, with no try |
reliability-reaches-the-network-unguarded | this body reaches the network through something it calls, with no try |
reliability-http-result-unchecked | an Http.* result used without asking whether the call worked — a 404 is a normal return and no try can catch it |
workflow-first-step-waits-on-a-slow-call | an autostarted workflow's first step reaches the network or a model — the commit that creates the row waits on it, for at most 5 seconds (Tracks and Initial (the field a workflow drives)) |
A helper that catches everything itself counts as handled, however many hops down it is, so you write the guard once where it means something rather than at every level.
using Osysharp.Workflow;
using Osysharp.Http;
// `use` is a MANIFEST declaration — it belongs in your app.osy, not in a model file.
app Invites {
model "model/**/*.osy";
use Osysharp.Http;
use Osysharp.Workflow;
}
class MailRequest { public string To; public string Subject; }
class MailResult { public string Id; }
// A typed client is an ordinary class over `Http.*` — see `osy docs http-client`.
public class Mailer {
public string BaseUrl = "https://api.mail.example";
public MailResult Send(MailRequest request) {
var r = Http.Post(BaseUrl + "/send", JsonSerializer.Serialize(request), "application/json");
if (!r.IsSuccess) { throw new ExternalServiceException("send failed: " + r.StatusCode + " " + r.Body); }
return JsonSerializer.Deserialize<MailResult>(r.Body);
}
}
entity Invitation {
[Required, MaxLength(200)] string Email;
InviteStatus Status; // no default: `InviteFlow` autostarts, so its `Initial` IS this field's first value
}
enum InviteStatus { Inviting, Pending, Accepted }
// The guard lives HERE, in the one place that knows what a failed send means: the invitation is still open, the run
// still moves on, and the failure is findable. Without it the send is retried with everything above it.
void SendInvite(Invitation inv) {
try {
var sent = new Mailer().Send(new MailRequest { To = inv.Email, Subject = "You are invited" });
}
catch (Exception e) {
Log.Error(e, "invitation mail failed for {Email}", inv.Email);
}
}
workflow InviteFlow {
Tracks = Invitation.Status;
Autostart = true;
Initial = Inviting;
event Accept();
// The run STARTS here and leaves on a clock due at once, so the mail below is sent after the commit that created
// the invitation — not inside it. A `goto Pending;` in an `enter` here would not do that: it is taken in the same
// first step (see `osy docs workflow-tracks`).
state Inviting {
Expire { Within = TimeSpan.Zero; Accrues = false; }
on Expire { goto Pending; }
}
state Pending {
enter { SendInvite(this.Item); }
subscribe Accept() as Acceptance;
on Acceptance { goto Accepted; }
}
terminal success Accepted { }
}Do enter/exit run on a terminal state?#
A terminal state can declare an enter { }, which runs as the run finishes. It can declare an exit { } too, but
nothing will ever run it: a terminal is where runs stop. Prefer putting the work in enter.
Examples#
Recording how long a review took — the pair working together, which is what exit exists for:
using Osysharp.Workflow;
entity Review {
[Required, MaxLength(200)] string Title;
DateTime? StartedAt;
int Minutes;
ReviewStatus Status = ReviewStatus.Pending;
}
enum ReviewStatus { Pending, UnderReview, Done }
workflow ReviewFlow {
Tracks = Review.Status;
Autostart = false;
Initial = Pending;
event Begin();
event Finish();
state Pending {
subscribe Begin() as B;
on B { goto UnderReview; }
}
state UnderReview {
enter { this.Item.StartedAt = DateTime.UtcNow; }
// Runs on the way out, whichever arm caused the move — so a second way out of this state cannot forget it.
exit { this.Item.Minutes = this.Item.Minutes + 1; }
subscribe Finish() as F;
on F { goto Done; }
}
terminal success Done { }
}That last point is the practical argument for exit over repeating the line at the end of every arm: a state with
three ways out needs the bookkeeping written once, and a fourth arm added later cannot silently omit it.
See also#
- complete when (a state's own completion condition) — ending a state on a condition rather than on a particular event
- Assigned / Finished (milestones) —
Assigned/Finishedtimers, whose ownentermay notgotoeither - ServiceHours (SLA-accrual windows) — what
Accruesmeasures, and therefore what an exit body is outside of - Tracks and Initial (the field a workflow drives) — the enum whose members these states are
- a typed HTTP client (a class over Http.*) — declaring the typed client a body like the one above calls
- When a child is cancelled or fails — what an uncaught workflow outcome does, which is not what a network fault does