Step 4: Wait for approval
An order over 1000 needs a person to approve it before it ships. The person answers at some later time, from outside the workflow. This step makes the workflow wait for that answer, for a limited time.
This page changes the program from step 3.
Wait for a signal
A signal is a named message sent to one workflow instance from outside. FCQRS stores it before it acknowledges it, and the body reads it as the result of a step that waits for that name.
In Order.fs (Order.cs), the result gets a status, shipped or not approved:
module Order
open System
open System.Threading.Tasks
open FCQRS.Common
open FCQRS.Workflows
// What an order workflow is started with. FCQRS stores it in the journal.
type OrderRequest = {
Customer: string
Item: string
Quantity: int
UnitPrice: decimal
}
// What the workflow returns. FCQRS stores it too.
type OrderResult = {
Status: string
Reservation: string
Shipment: string
Confirmation: Guid
PlacedAt: DateTime
}
Fcqrs_350-workflow-tutorial_004-wait-for-approval.md_page.OrderSystemThreadingTasksFCQRSFCQRS.CommonContains common types like Events and Commands Functionality for Write Side.
WorkflowsFcqrs_350-workflow-tutorial_004-wait-for-approval.md_page.Order.OrderRequestCustomer: stringstringAn abbreviation for the CLI type . Basic Types
Item: stringQuantity: intintAn abbreviation for the CLI type . Basic Types
UnitPrice: decimaldecimalAn abbreviation for the CLI type . Basic Types
Fcqrs_350-workflow-tutorial_004-wait-for-approval.md_page.Order.OrderResultStatus: stringReservation: stringShipment: stringConfirmation: GuidSystem.GuidRepresents a globally unique identifier (GUID).
PlacedAt: DateTimeSystem.DateTimeRepresents an instant in time, typically expressed as a date and time of day.
using FCQRS.Workflows;
using FCQRS.Workflows.CSharp;
using static FCQRS.CSharp;
// What an order workflow is started with. FCQRS stores it in the journal.
public sealed record OrderRequest(
string Customer, string Item, int Quantity, decimal UnitPrice);
// What the workflow returns. FCQRS stores it too.
public sealed record OrderResult(
string Status, string Reservation, string Shipment, Guid Confirmation,
DateTime PlacedAt);
Replace the workflow. After the confirmation number, an order over 1000 waits for the signal
approval:
Shared setup
// Unchanged from step 3.
type IWarehouse =
abstract Reserve:
key: string * item: string * quantity: int -> Task<string>
type ICarrier =
abstract Ship: key: string * reservation: string -> Task<string>
let reserveRetry = Retry.times 5
Fcqrs_350-workflow-tutorial_004-wait-for-approval.md_page.Order.IWarehouseReserve: IWarehouse -> string * string * int -> Task<string>keystringAn abbreviation for the CLI type . Basic Types
itemquantityintAn abbreviation for the CLI type . Basic Types
System.Threading.Tasks.Task`1Represents an asynchronous operation that can return a value. The type of the result produced by this .
Fcqrs_350-workflow-tutorial_004-wait-for-approval.md_page.Order.ICarrierShip: ICarrier -> string * string -> Task<string>reservationreserveRetry: RetryPolicyFCQRS.Workflows.RetryCommon retry policies.
times: int -> RetryPolicyAt most `attempts` attempts, with backoff from one second to one minute.
// "order" is stored with every instance. It must not change.
let definition (warehouse: IWarehouse) (carrier: ICarrier) =
Workflow.define "order" (fun (request: OrderRequest) ->
workflow {
// A failed attempt is not stored. FCQRS tries again until an
// attempt succeeds or the policy gives up.
let! reservation =
activity "reserve-stock" reserveRetry (fun attempt ->
warehouse.Reserve(
attempt.IdempotencyKey, request.Item, request.Quantity))
// The body must give the same answers on every replay, so it
// reads the clock and makes GUIDs only through steps. FCQRS
// stores their values and replays them.
let! placedAt = now
let! confirmation = newGuid
// An order over 1000 waits for a person to approve it, for at
// most three seconds in this program. With no answer by then,
// the wait returns None.
let total = request.UnitPrice * decimal request.Quantity
let approvalWait = TimeSpan.FromSeconds 3.0
let! approved =
workflow {
if total <= 1000m then
return true
else
let! answer =
waitSignalFor<bool> "approval" approvalWait
return defaultArg answer false
}
if not approved then
return {
Status = "not approved"
Reservation = reservation
Shipment = ""
Confirmation = confirmation
PlacedAt = placedAt
}
else
// Packing takes two seconds. The timer's due time is
// stored, so a restart does not start the wait again.
do! sleep (TimeSpan.FromSeconds 2.0)
let! shipment =
activity "ship" (Retry.times 3) (fun attempt ->
carrier.Ship(attempt.IdempotencyKey, reservation))
return {
Status = "shipped"
Reservation = reservation
Shipment = shipment
Confirmation = confirmation
PlacedAt = placedAt
}
})
definition: IWarehouse -> ICarrier -> WorkflowDefinition<OrderRequest,OrderResult>warehouse: IWarehouseFcqrs_350-workflow-tutorial_004-wait-for-approval.md_page.Order.IWarehousecarrier: ICarrierFcqrs_350-workflow-tutorial_004-wait-for-approval.md_page.Order.ICarrierFCQRS.Workflows.WorkflowDefines workflows.
define: string -> ('Input -> Workflow<'Result>) -> WorkflowDefinition<'Input,'Result>A workflow named `name` whose instances run `body` on their input.
request: OrderRequestFcqrs_350-workflow-tutorial_004-wait-for-approval.md_page.Order.OrderRequestworkflow: WorkflowBuilderBuilds a workflow: `workflow { let! x = activity ... }`.
reservation: stringactivity: string -> RetryPolicy -> (AttemptContext -> Task<'T>) -> Workflow<'T>Calls an external system. The call runs at least once: a crash after the call and before its result is stored repeats it, so pass `IdempotencyKey` to the external system. `name` is stored and must not change while instances are running.
reserveRetry: RetryPolicyattempt: AttemptContextReserve: string * string * int -> Task<string>IdempotencyKey: stringThe same for every attempt of this step, across restarts: workflow name, workflow ID, and step path. Pass it to the external system so a repeated call is recognised.
Item: stringQuantity: intplacedAt: DateTimenow: Workflow<DateTime>The current UTC time, stored so that replay returns the same value.
confirmation: GuidnewGuid: Workflow<Guid>A new GUID, stored so that replay returns the same value.
total: decimalUnitPrice: decimal(*): ^T1 -> ^T2 -> ^T3Overloaded multiplication operator The first parameter. The second parameter. The result of the operation. 8 * 6 // Evaluates to 48
decimal: ^T -> decimalConverts the argument to System.Decimal using a direct conversion for all primitive numeric types. For strings, the input is converted using UInt64.Parse() with InvariantCulture settings. Otherwise the operation requires an appropriate static conversion method on the input type. The input value. The converted decimal. decimal "42.23" // evaluates to 42.23M decimal 0xff // evaluates to 255M decimal -10 // evaluates to -10M
approvalWait: TimeSpanSystem.TimeSpanRepresents a time interval.
FromSeconds: float -> TimeSpanReturns a that represents a specified number of seconds, where the specification is accurate to the nearest millisecond. A number of seconds, accurate to the nearest millisecond. is less than TimeSpan.MinValue or greater than TimeSpan.MaxValue. -or- is . -or- is . is equal to . An object that represents .
approved: bool(<=): 'T -> 'T -> boolStructural less-than-or-equal comparison The first parameter. The second parameter. The result of the comparison. 5 <= 1 // Evaluates to false 5 <= 5 // Evaluates to true [1; 5] <= [1; 6] // Evaluates to true
answer: bool optionwaitSignalFor: string -> TimeSpan -> Workflow<'T option>Waits for a signal named `name` for at most `timeout`, measured from the stored start of the step. Gives `None` when the time passes first; a signal that arrives later stays stored for the next wait.
boolAn abbreviation for the CLI type . Basic Types
defaultArg: 'T option -> 'T -> 'TUsed to specify a default value for an optional argument in the implementation of a function An option representing the argument. The default value of the argument. The argument value. If it is None, the defaultValue is returned. type Vector(x: double, y: double, ?z: double) = let z = defaultArg z 0.0 member this.X = x member this.Y = y member this.Z = z let v1 = Vector(1.0, 2.0) v1.Z // Evaluates to 0. let v2 = Vector(1.0, 2.0, 3.0) v2.Z // Evaluates to 3.0
``not``: bool -> boolNegate a logical value. Not True equals False and not False equals True The value to negate. The result of the negation. not (2 + 2 = 5) // Evaluates to true // not is a function that can be compose with other functions let fileDoesNotExist = System.IO.File.Exists >> not
Status: stringReservation: stringShipment: stringConfirmation: GuidPlacedAt: DateTimesleep: TimeSpan -> Workflow<unit>Waits for `duration`. The due time is stored when the step is first reached, so a restart does not postpone it.
shipment: stringFCQRS.Workflows.RetryCommon retry policies.
times: int -> RetryPolicyAt most `attempts` attempts, with backoff from one second to one minute.
Ship: string * string -> Task<string>// "order" is stored with every instance. It must not change.
public sealed class OrderWorkflow(IWarehouse warehouse, ICarrier carrier)
: Workflow<OrderRequest, OrderResult>("order")
{
// Up to five attempts, 200 ms apart.
private static readonly RetryPolicy ReserveRetry = Retry.times(5)
.WithSchedule(RetrySchedules.Fixed(TimeSpan.FromMilliseconds(200)));
public override async WorkflowTask<OrderResult> Run(
WorkflowContext ctx, OrderRequest request)
{
// A failed attempt is not stored. FCQRS tries again until an
// attempt succeeds or the policy gives up.
var reservation = await ctx.Activity("reserve-stock", ReserveRetry,
attempt => warehouse.Reserve(
attempt.IdempotencyKey, request.Item, request.Quantity));
// The body must give the same answers on every replay, so it
// reads the clock and makes GUIDs only through steps. FCQRS
// stores their values and replays them.
var placedAt = await ctx.Now();
var confirmation = await ctx.NewGuid();
// An order over 1000 waits for a person to approve it, for at
// most three seconds in this program. With no answer by then,
// Received is false.
var total = request.UnitPrice * request.Quantity;
if (total > 1000m)
{
var answer = await ctx.WaitSignal<bool>(
"approval", TimeSpan.FromSeconds(3));
if (!(answer.Received && answer.Value))
return new OrderResult(
"not approved", reservation, "", confirmation, placedAt);
}
// Packing takes two seconds. The timer's due time is stored, so
// a restart does not start the wait again.
await ctx.Sleep(TimeSpan.FromSeconds(2));
var shipment = await ctx.Activity("ship", Retry.times(3),
attempt => carrier.Ship(attempt.IdempotencyKey, reservation));
return new OrderResult(
"shipped", reservation, shipment, confirmation, placedAt);
}
}
waitSignalFor<bool> "approval" (ctx.WaitSignal<bool> in C#) is a step at position 3. It ends when
a signal named approval with a bool payload arrives, or when its timeout passes. The timeout's
due time is stored when the body reaches the step, as a timer's is, so a restart does not extend
it. A real approval would wait days; this program waits three seconds.
An order of 1000 or less does not reach the wait, so its sleep and ship steps are at positions
3 and 4, and a large order's are at 4 and 5. The positions belong to each order's own run of the
body. Both kinds of order replay correctly, because each replays its own journal with the same
input.
Send the signal
In Program.fs (Program.cs), replace the code that starts the order. The program now places
three orders, one after another:
Shared setup
open System
open FCQRS.Workflows
open Order
// Unchanged from step 3.
let orders: WorkflowHandle<OrderRequest, OrderResult> = Unchecked.defaultof<_>
SystemFCQRSWorkflowsFcqrs_350-workflow-tutorial_004-wait-for-approval.md_page.Orderorders: WorkflowHandle<OrderRequest,OrderResult>FCQRS.Workflows.WorkflowHandle`2Starts and queries the instances of a registered workflow.
Fcqrs_350-workflow-tutorial_004-wait-for-approval.md_page.Order.OrderRequestFcqrs_350-workflow-tutorial_004-wait-for-approval.md_page.Order.OrderResultMicrosoft.FSharp.Core.Operators.UncheckedThis module contains basic operations which do not apply runtime and/or static checks
defaultof: 'TGenerate a default value for any type. This is null for reference types, For structs, this is struct value where all fields have the default value. This function is unsafe in the sense that some F# values do not have proper null values.
// Start an order. A start with an ID that is already in the journal stores
// nothing and returns the order's status.
let start id request =
match orders.Start id request |> Async.RunSynchronously with
| StartResult.Started -> printfn $"Started {id}"
| StartResult.AlreadyStarted status ->
printfn $"{id} is already started ({status})"
// Start an order, send it an approval if given one, and wait for its end.
let place id quantity approval =
let request = {
Customer = "ada"
Item = "widget"
Quantity = quantity
UnitPrice = 40m
}
start id request
match approval with
| Some approve ->
Workflows.signal orders id "approval" approve |> Async.RunSynchronously
| None -> ()
let outcome =
orders.Result id (TimeSpan.FromSeconds 30.0) |> Async.RunSynchronously
match outcome with
| WorkflowOutcome.Finished result when result.Status = "shipped" ->
printfn $"{id} finished: shipped as {result.Shipment}"
| WorkflowOutcome.Finished result ->
printfn $"{id} finished: {result.Status}"
| other -> printfn $"{id} ended: {other}"
place "o-1" 2 None
// 30 widgets at 40 cost 1200. The approval is sent before the order waits
// for it, and the order keeps it until then.
place "o-2" 30 (Some true)
// 50 widgets cost 2000. Nobody approves this order.
place "o-3" 50 None
start: string -> OrderRequest -> unitid: stringrequest: OrderRequestorders: WorkflowHandle<OrderRequest,OrderResult>Start: string -> 'Input -> Async<StartResult>Starts the workflow with this ID. Returns once the start is stored. A second start with the same ID stores nothing and reports the existing instance.
(|>): 'T1 -> ('T1 -> 'U) -> 'UApply a function to a value, the value being on the left, the function on the right The argument. The function. The function result. let doubleIt x = x * 2 3 |> doubleIt // Evaluates to 6
Microsoft.FSharp.Control.FSharpAsyncHolds static members for creating and manipulating asynchronous computations. See also F# Language Guide - Async Workflows. Async Programming
RunSynchronously: Async<'T> * int option * Threading.CancellationToken option -> 'TRuns the asynchronous computation and await its result. If an exception occurs in the asynchronous computation then an exception is re-raised by this function. If no cancellation token is provided then the default cancellation token is used. The computation is started on the current thread if is null, has of true, and no timeout is specified. Otherwise the computation is started by queueing a new work item in the thread pool, and the current thread is blocked awaiting the completion of the computation. The timeout parameter is given in milliseconds. A value of -1 is equivalent to . The computation to run. The amount of time in milliseconds to wait for the result of the computation before raising a . If no value is provided for timeout then a default of -1 is used to correspond to . The cancellation token to be associated with the computation. If one is not supplied, the default cancellation token is used. The result of the computation. Starting Async Computations printfn "A" let result = async { printfn "B" do! Async.Sleep(1000) printfn "C" 17 } |> Async.RunSynchronously printfn "D" Prints "A", "B" immediately, then "C", "D" in 1 second. result is set to 17.
FCQRS.Workflows.StartResultThe answer to a start request.
StartedThis request started the workflow.
printfn: Printf.TextWriterFormat<'T> -> 'TPrint to stdout using the given format, and add a newline. The formatter. The formatted result. See Printf.printfn (link: ) for examples.
AlreadyStartedA workflow with this ID already exists. Nothing was stored.
status: WorkflowStatusplace: string -> int -> 'a option -> unitquantity: intapproval: 'a optionCustomer: stringItem: stringQuantity: intUnitPrice: decimalSomeThe representation of "Value of type 'T" The input value. An option representing the value.
approve: 'aFCQRS.Workflows.WorkflowsRegisters workflows with an actor system.
signal: WorkflowHandle<'Input,'Result> -> string -> string -> 'Signal -> Async<unit>Stores the signal `name` with a typed payload for the instance `id`.
NoneThe representation of "No value"
outcome: WorkflowOutcome<OrderResult>Result: string -> TimeSpan -> Async<WorkflowOutcome<'Result>>Waits up to the given time for the instance to end. The wait is in memory: the workflow continues when the caller stops waiting. Raises `TimeoutException` when the time passes, and `InvalidOperationException` when no workflow with this ID was started.
System.TimeSpanRepresents a time interval.
FromSeconds: float -> TimeSpanReturns a that represents a specified number of seconds, where the specification is accurate to the nearest millisecond. A number of seconds, accurate to the nearest millisecond. is less than TimeSpan.MinValue or greater than TimeSpan.MaxValue. -or- is . -or- is . is equal to . An object that represents .
FCQRS.Workflows.WorkflowOutcome`1How a workflow ended.
FinishedThe body returned this value.
result: OrderResultStatus: string(=): 'T -> 'T -> boolStructural equality The first parameter. The second parameter. The result of the comparison. 5 = 5 // Evaluates to true 5 = 6 // Evaluates to false [1; 2] = [1; 2] // Evaluates to true (1, 5) = (1, 6) // Evaluates to false
other: WorkflowOutcome<OrderResult>// Start an order. A start with an ID that is already in the journal stores
// nothing and returns false.
async Task Start(string id, OrderRequest request)
{
if (await orders.StartAsync(id, request))
Console.WriteLine($"Started {id}");
else
Console.WriteLine(
$"{id} is already started ({await orders.StatusAsync(id)})");
}
// Start an order, send it an approval if given one, and wait for its end.
async Task Place(string id, int quantity, bool? approval)
{
await Start(id, new OrderRequest("ada", "widget", quantity, 40m));
if (approval is bool approve)
await orders.SignalAsync(id, "approval", approve);
var result = await orders.ResultAsync(id, TimeSpan.FromSeconds(30));
Console.WriteLine(result.Status == "shipped"
? $"{id} finished: shipped as {result.Shipment}"
: $"{id} finished: {result.Status}");
}
await Place("o-1", 2, null);
// 30 widgets at 40 cost 1200. The approval is sent before the order waits
// for it, and the order keeps it until then.
await Place("o-2", 30, true);
// 50 widgets cost 2000. Nobody approves this order.
await Place("o-3", 50, null);
Workflows.signal orders id "approval" true (orders.SignalAsync in C#) returns once FCQRS has
stored the signal. The program sends o-2's approval right after the start, while the order is
still reserving stock.
Run it
Delete bin/Debug/net11.0/orders.db, then run dotnet run. It takes about ten seconds:
Started o-1
warehouse busy (key order/o-1/0)
warehouse busy (key order/o-1/0)
warehouse reserved 2 widget (key order/o-1/0)
carrier shipped r-1 (key order/o-1/4)
o-1 finished: shipped as s-1
Started o-2
warehouse busy (key order/o-2/0)
warehouse busy (key order/o-2/0)
warehouse reserved 30 widget (key order/o-2/0)
carrier shipped r-2 (key order/o-2/5)
o-2 finished: shipped as s-2
Started o-3
warehouse busy (key order/o-3/0)
warehouse busy (key order/o-3/0)
warehouse reserved 50 widget (key order/o-3/0)
o-3 finished: not approved
Journal of order/default-shard/o-1:
1 Started
2 StepScheduled
3 StepCompleted
4 StepCompleted
5 StepCompleted
6 StepScheduled
7 StepCompleted
8 StepScheduled
9 StepCompleted
10 Finished
o-1costs 80, so it skips the wait. Its journal is the same as in step 3.o-2costs 1200. Its approval arrived before the body asked for it. FCQRS stored the signal when it arrived, and the wait at position 3 found it, so the order shipped without waiting. A signal sent early is not lost. The carrier's key ends in 5, because the wait took position 3.o-3costs 2000 and gets no approval. After three seconds its wait returned no answer, and the order finished asnot approved.
o-3 still holds a reservation at the warehouse: nothing releases it. Step 6 adds a step that
releases the stock of an order that does not ship.
Next
Step 5: Restart during shipping stops the process in the middle of an order and starts it again.