Osy#betaRun it on Osyrin: osyrin.com

Reference / Workflow

Workflow.Run (start a workflow)

Workflow.Run(entity) · await Workflow.Run("label", entity)

Start the workflow bound to an entity's type, on that entity. Bare — `Workflow.Run(order)` — is fire-and-forget: start it and carry on. Awaited — `await Workflow.Run("fulfil", order)` — is a durable wait on the started (child) workflow: hold until it reaches a terminal, then return on success or throw `WorkflowError` / `WorkflowCancelled` on an error / cancel terminal, so a parent flow can compensate with an ordinary `try`/`catch`.

stable4 examples compiled by CIworkflowauthoringsaga

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 throws WorkflowError; a cancel terminal throws WorkflowCancelled. That makes a child workflow a step you can wrap in an ordinary try/catch and 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 outcome

entity 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 on

Use 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 success terminal → the await returns and the next line runs;
  • an error terminal → the await throws WorkflowError;
  • a cancel terminal → the await throws WorkflowCancelled.

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 succeeded

After 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.Run on 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.
  • await is the wait, and the only wait. Workflow.Run without await never blocks; with await it holds until the started workflow terminates. Writing await on 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 compensating await inside a catch (or a finally) works too — the whole try/catch survives 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 without await is a run of its own and is not.
  • Several children at once, each with a result: start an async function that awaits its child as a task, and await Task.WhenAll(…) over them (Tasks that wait (Task.WhenAll across days)).

See also#

Related

Raising a workflow event

Raise a typed event on the run bound to an entity, from anywhere — an ordinary server function, a webhook handler, a…

Step labels (naming a child run so it survives a new version)

Every child workflow you AWAIT carries a label — a literal string you choose, naming that step. Awaiting parks the run…

For(entity).Audit

Reads a running instance's lifecycle timeline — every transition, claim, deposit, reminder and refusal as an…