Summary#
Workflow.Run(order) starts the workflow whose target binds the entity's type, on that entity — the same
inference Raising a workflow event uses for its events. There are two forms, and the only difference is await:
Workflow.Run(order)(bare) — fire-and-forget: start it and carry straight on. The call returns nothing.await Workflow.Run("fulfil", order)— a durable wait: hold here until the started workflow reaches a terminal, then hand its outcome back. A success terminal lets the next line run; an error terminal throwsWorkflowError; a cancel terminal throwsWorkflowCancelled. That makes a child workflow a step you can wrap in an ordinarytry/catchand compensate — the Saga pattern.
await carries meaning only on Workflow.Run — it is the one place in Osy# where you wait. Everywhere else effects
run in place, so await is never written.
Signature#
Workflow.Run(order) // fire-and-forget — start it, carry on; returns nothing
await Workflow.Run("fulfil", order) // wait for the started workflow's terminal, then return / throw on its outcomeentity is an entity-typed value whose type has exactly one workflow bound to it in the same unit. More than one is a
compile error (the target is ambiguous); none is a compile error (nothing to start). It is any expression of that type
— a local, a parameter, or a member read such as await Workflow.Run("check", this.Item.Parcel) — and the run starts
on the row it answers. The same holds for saga.Run and for Raising a workflow event's event-constructor form.
Description#
A workflow is bound to an entity type (Tracks = <Entity>.<Enum>;). Workflow.Run(order) starts that workflow on the
given order row. Use the bare form when the started workflow runs independently — you don't need its result:
var welcome = new WelcomeEmail { Customer = this.Item };
Workflow.Run(welcome); // kick it off; this flow carries onUse the awaited form when the started workflow is a step whose outcome you act on — the essence of a Saga. The
started (child) workflow's terminal surfaces at the await:
- a
successterminal → theawaitreturns and the next line runs; - an
errorterminal → theawaitthrowsWorkflowError; - a
cancelterminal → theawaitthrowsWorkflowCancelled.
Both faults carry the terminal state's message, and both are ordinary catchable exceptions — so a compensating flow is
just try/catch:
try {
await Workflow.Run("payment", payment); // hold until the payment workflow reaches a terminal
goto Confirmed; // reached only if it SUCCEEDED
} catch (WorkflowError e) {
await Workflow.Run("refundHold", new RefundHold { Order = this.Item }); // compensate — undo the earlier step
goto Refunded;
}WorkflowError (an error terminal) and WorkflowCancelled (a deliberate cancel) are distinct on purpose: catch them
separately when a cancel is not a failure. A catch (Exception e) still catches either.
Examples#
using Osysharp.Workflow;
enum OrderState { Placed, Done }
[Principal]
entity Person {
[Required, MaxLength(200)] string Email;
security { allow read, create when IsAuthenticated; }
}
entity Order {
[Required, MaxLength(60)] string Reference;
OrderState Status; // no default: the workflow owns this field
security { allow read, create, update when IsAuthenticated; }
}
enum NoticeState { Sending, Sent }
entity ShipmentNotice {
[Required] Order Order;
NoticeState Status;
security { allow read, create, update when IsAuthenticated; }
}
workflow NoticeFlow {
Tracks = ShipmentNotice.Status;
Autostart = true;
Initial = Sending;
state Sending { on Complete { goto Sent; } }
terminal success Sent { }
}
workflow OrderFlow {
Tracks = Order.Status;
Autostart = true;
Initial = Placed;
event Ship();
state Placed {
subscribe Ship();
on Ship {
// Fire-and-forget: the notice runs on its own, with its own durability.
// The argument is an entity-typed VARIABLE — `Workflow.Run(new …)` is refused.
var notice = new ShipmentNotice { Order = this.Item };
Workflow.Run(notice);
goto Done;
}
}
terminal success Done { }
}Fire-and-forget — start a notification workflow and move on:
var notice = new ShipmentNotice { Order = this.Item };
Workflow.Run(notice);Awaited step with compensation — the Saga shape:
try {
await Workflow.Run("reservation", reservation); // a child workflow; wait for its terminal
} catch (WorkflowError e) {
goto Rejected; // it failed — route accordingly
} catch (WorkflowCancelled e) {
goto Cancelled; // it was cancelled — a different route
}
goto Reserved; // it succeededAfter the wait the body is the same body it was before it — the app's own code, with no caller, reading the run's clock — however long the child took:
using Osysharp.Workflow;
[Principal] entity Person { [Required, MaxLength(200)] string Email; security { allow read when IsAuthenticated; } }
[Role] enum YardRole { Hand, Boss }
[RoleGrant] entity RoleGrant { [Required] Person Grantee; [Required] YardRole Level; security { allow read when IsAuthenticated; } }
policy IsBoss => RoleGrant.Any(g => g.Grantee == user && g.Level == YardRole.Boss);
enum SurveyStatus { Booked, Done }
enum LiftStatus { Waiting, Lifted }
entity Survey {
SurveyStatus Status = SurveyStatus.Booked;
security { allow read, create, update when IsAuthenticated || IsAnonymous; }
}
workflow SurveyFlow {
Tracks = Survey.Status; Autostart = false; Initial = Booked;
event Finish();
state Booked { subscribe Finish(); on Finish { goto Done; } }
terminal success Done { }
}
entity Lift {
LiftStatus Status = LiftStatus.Waiting;
DateTime? BookedAt;
DateTime? LiftedAt;
[MaxLength(80)] string CraneBooking = "";
security { allow read, create, update when IsAuthenticated || IsAnonymous; }
}
/// Only the boss books the crane — when a PERSON asks. A process is the yard itself.
[Authorize(IsBoss)]
string BookTheCrane() { return "crane-7"; }
workflow LiftFlow {
Tracks = Lift.Status; Autostart = false; Initial = Waiting;
state Waiting {
enter {
this.Item.BookedAt = DurableClock.UtcNow;
var survey = Workflow.Once("survey", () => new Survey { });
await Workflow.Run("survey", survey); // days pass here
this.Item.CraneBooking = BookTheCrane(); // …and the body is still the app: it is not asked for a role
this.Item.LiftedAt = DurableClock.UtcNow; // …reading the run's clock, as it did before the wait
goto Lifted;
}
}
terminal success Lifted { }
}
[Test]
void after_the_wait_the_body_is_still_the_app_and_reads_the_runs_clock() {
TestClock.Set(new DateTime(2031, 5, 5, 8, 0, 0));
var lift = new Lift { };
Workflow.Run(lift); // started at the test's clock, not the wall clock
Workflow.Settle(lift);
Assert.Equal(new DateTime(2031, 5, 5, 8, 0, 0), lift.BookedAt);
Assert.Equal(LiftStatus.Waiting, lift.Status);
TestClock.Advance(TimeSpan.FromDays(3));
SurveyFlow.RaiseFinish(Survey.Single());
Workflow.Settle(lift);
Assert.Equal(LiftStatus.Lifted, lift.Status);
Assert.Equal("crane-7", lift.CraneBooking);
Assert.Equal(new DateTime(2031, 5, 8, 8, 0, 0), lift.LiftedAt);
}A state that is entered after a wait can wait too — each state's own enter {} awaits what it needs, and the run
goes from one to the next:
using Osysharp.Workflow;
enum CheckStatus { Booked, Done }
enum OrderStatus { Checking, Packing, Shipped }
entity Check {
[MaxLength(40)] string What = "";
CheckStatus Status = CheckStatus.Booked;
security { allow read, create, update when IsAuthenticated || IsAnonymous; }
}
workflow CheckFlow {
Tracks = Check.Status; Autostart = false; Initial = Booked;
event Finish();
state Booked { subscribe Finish(); on Finish { goto Done; } }
terminal success Done { }
}
entity Order {
OrderStatus Status = OrderStatus.Checking;
[MaxLength(200)] string Log = "";
security { allow read, create, update when IsAuthenticated || IsAnonymous; }
}
workflow OrderFlow {
Tracks = Order.Status; Autostart = false; Initial = Checking;
state Checking {
enter {
var credit = Workflow.Once("credit", () => new Check { What = "credit" });
await Workflow.Run("credit", credit); // the first wait
this.Item.Log = this.Item.Log + "credit ok; ";
goto Packing; // …and on to a state whose own entry waits
}
}
state Packing {
enter {
var stock = Workflow.Once("stock", () => new Check { What = "stock" });
await Workflow.Run("stock", stock); // the second wait, in the next state
this.Item.Log = this.Item.Log + "stock ok";
goto Shipped;
}
}
terminal success Shipped { }
}
[Test]
void a_state_entered_after_a_wait_can_wait_too() {
var order = new Order { };
Workflow.Run(order);
Workflow.Settle(order);
Assert.Equal(OrderStatus.Checking, order.Status);
CheckFlow.RaiseFinish(Check.Single(c => c.What == "credit"));
Workflow.Settle(order);
// It went on to Packing — and is waiting there, on the second check.
Assert.Equal(OrderStatus.Packing, order.Status);
Assert.Equal("credit ok; ", order.Log);
CheckFlow.RaiseFinish(Check.Single(c => c.What == "stock"));
Workflow.Settle(order);
Assert.Equal(OrderStatus.Shipped, order.Status);
Assert.Equal("credit ok; stock ok", order.Log);
}A row whose workflow has already started — one declared Autostart = true starts when the row is saved, and
somebody else may have run it — is not started a second time. await Workflow.Run("credit", check) waits on the run
the row already has, and is handed its outcome; if that run ended earlier, the next line runs at once:
using Osysharp.Workflow;
enum CheckStatus { Booked, Done, Failed }
enum OrderStatus { Checking, Shipped, Refused }
entity Check {
[MaxLength(40)] string What = "";
CheckStatus Status; // no default: the workflow owns this field
security { allow read, create, update when IsAuthenticated || IsAnonymous; }
}
workflow CheckFlow {
Tracks = Check.Status; Autostart = true; Initial = Booked; // every Check gets its run when it is saved
event Finish();
event Fail();
state Booked {
subscribe Finish();
subscribe Fail();
on Finish { goto Done; }
on Fail { goto Failed; }
}
terminal success Done { }
terminal error Failed { Message = "no credit"; }
}
entity Order {
OrderStatus Status = OrderStatus.Checking;
[MaxLength(200)] string Log = "";
security { allow read, create, update when IsAuthenticated || IsAnonymous; }
}
workflow OrderFlow {
Tracks = Order.Status; Autostart = false; Initial = Checking;
state Checking {
enter {
var credit = Workflow.Once("credit", () => new Check { What = "credit" }); // saved — and already running
try {
await Workflow.Run("credit", credit); // waits on that run; a second one is not started
this.Item.Log = "credit ok";
goto Shipped;
} catch (WorkflowError e) {
this.Item.Log = "refused: " + e.Message;
goto Refused;
}
}
}
terminal success Shipped { }
terminal success Refused { }
}
[Test]
void the_order_goes_on_when_the_check_that_started_with_its_row_ends() {
var order = new Order { };
Workflow.Run(order);
Workflow.Settle(order);
Assert.Equal(OrderStatus.Checking, order.Status);
CheckFlow.RaiseFinish(Check.Single());
Workflow.Settle(order);
Assert.Equal(OrderStatus.Shipped, order.Status);
Assert.Equal("credit ok", order.Log);
}
[Test]
void and_is_handed_its_failure() {
var order = new Order { };
Workflow.Run(order);
Workflow.Settle(order);
CheckFlow.RaiseFail(Check.Single());
Workflow.Settle(order);
Assert.Equal(OrderStatus.Refused, order.Status);
Assert.Equal("refused: no credit", order.Log);
}Notes#
- One run per row.
Workflow.Runon a row whose workflow is already running — it autostarted, or was started elsewhere — does not start another: the bare form does nothing more, and the awaited form waits on the run that is there. A state entered a second time still starts its own child, as each visit of a state does. awaitis the wait, and the only wait.Workflow.Runwithoutawaitnever blocks; withawaitit holds until the started workflow terminates. Writingawaiton anything else is a compile error.- The started workflow is inferred from the entity's type, exactly like Raising a workflow event. Keep one workflow per bound type, or the target is ambiguous.
- The wait is durable. When the started workflow waits — on a human step, a timer, or its own child — the awaiting
flow parks: it is persisted and lifted off the thread, then resumes exactly where it paused when the child
reaches a terminal, even across a restart. You write straight-line
await; the platform owns the pause. Because it parks, the compensatingawaitinside acatch(or afinally) works too — the wholetry/catchsurvives the wait. - A run that is ended while it waits calls the child off. If something else ends the awaiting run — an event arm
sends it to a terminal, somebody stops it — the child it was waiting for is stopped too, at its own
terminal cancel(What happens to work a run stops waiting for). A child started withoutawaitis a run of its own and is not. - Several children at once, each with a result: start an
asyncfunction that awaits its child as a task, andawait Task.WhenAll(…)over them (Tasks that wait (Task.WhenAll across days)).
See also#
- Workflows that outlive the code that started them — what happens to a run parked on an awaited child when you deploy past it
- Raising a workflow event — send a typed event to a running workflow.
- <span class="planned" title="this page is planned and not written yet">workflow-goto</span> — transition within a workflow body.
- For(entity).Audit — read a workflow's timeline of events.
- Step labels (naming a child run so it survives a new version) — naming an awaited child run so a parked run survives a new version.
- What happens to work a run stops waiting for — what happens to a child when the run awaiting it is ended.
- Tasks that wait (Task.WhenAll across days) —
Task.WhenAllover functions that each await a child run.