Write a workflow
This recipe builds a quote workflow. It fetches a price from an external service, waits for a person to approve a large quote, and sends the quote. The process can stop at any point; the next start continues the quote where it was.
Read Workflows first for the model: steps, the journal, replay, and what stays the application's responsibility. The workflow tutorial teaches the same steps one at a time.
Motivation: Use a workflow here because a caller starts the process with input data, the work is a sequence of external calls, and it must finish even if the process restarts in the middle.
The complete programs are in samples/workflows/fsharp and samples/workflows/csharp.
FCQRS.Workflows is a separate package; the samples reference its project.
Define the input and the result
Both are stored in the workflow's journal, so their shape is a persistent contract:
Shared setup
module Quote =
open System
open System.Threading.Tasks
open FCQRS.Common
open FCQRS.Workflows
Fcqrs_550-how-to_008-write-a-workflow.md_page.QuoteSystemThreadingTasksFCQRSFCQRS.CommonContains common types like Events and Commands Functionality for Write Side.
Workflows// What the workflow is started with.
type QuoteRequest = {
Customer: string
Symbol: string
Quantity: int
}
// What the workflow returns.
type Quote = {
Total: decimal
Approved: bool
Receipt: string
}
Fcqrs_550-how-to_008-write-a-workflow.md_page.Quote.QuoteRequestCustomer: stringstringAn abbreviation for the CLI type . Basic Types
Symbol: stringQuantity: intintAn abbreviation for the CLI type . Basic Types
Fcqrs_550-how-to_008-write-a-workflow.md_page.Quote.QuoteTotal: decimaldecimalAn abbreviation for the CLI type . Basic Types
Approved: boolboolAn abbreviation for the CLI type . Basic Types
Receipt: string// What the workflow is started with.
public sealed record QuoteRequest(string Customer, string Symbol, int Quantity);
// What the workflow returns.
public sealed record Quote(decimal Total, bool Approved, string Receipt);
Give external calls an idempotency key
An activity runs at least once. A crash after the call and before its result is stored repeats the call, so each external system takes a key that is the same for every attempt of one step:
// The external systems the workflow calls. Each call gets a key that is the
// same for every attempt of one step, so a system that remembers keys can
// recognise a repeated call.
type IPriceService =
abstract Fetch: key: string * symbol: string -> Task<decimal>
type IMailer =
abstract Send:
key: string * customer: string * total: decimal -> Task<string>
Fcqrs_550-how-to_008-write-a-workflow.md_page.Quote.IPriceServiceFetch: IPriceService -> string * string -> Task<decimal>keystringAn abbreviation for the CLI type . Basic Types
symbolSystem.Threading.Tasks.Task`1Represents an asynchronous operation that can return a value. The type of the result produced by this .
decimalAn abbreviation for the CLI type . Basic Types
Fcqrs_550-how-to_008-write-a-workflow.md_page.Quote.IMailerSend: IMailer -> string * string * decimal -> Task<string>customertotal// The external systems the workflow calls. Each call gets a key that is the
// same for every attempt of one step, so a system that remembers keys can
// recognise a repeated call.
public interface IPriceService
{
Task<decimal> Fetch(string key, string symbol);
}
public interface IMailer
{
Task<string> Send(string key, string customer, decimal total);
}
Write the steps in order
// Five attempts, 200 ms apart.
let private retry = {
Retry.times 5 with
Schedule = FixedInterval(TimeSpan.FromMilliseconds 200.0)
}
let definition (prices: IPriceService) (mailer: IMailer) =
Workflow.define "quote" (fun (request: QuoteRequest) -> workflow {
// The price is stored once it arrives. A restart after this step
// does not call the price service again.
let! price =
activity "price" retry (fun attempt ->
prices.Fetch(attempt.IdempotencyKey, request.Symbol))
let total = price * decimal request.Quantity
// A quote over 1000 waits for a person, for at most one day.
let! approved = workflow {
if total <= 1000m then
return true
else
let! answer =
waitSignalFor<bool> "approval" (TimeSpan.FromDays 1.0)
return defaultArg answer false
}
if not approved then
return { Total = total; Approved = false; Receipt = "" }
else
let! receipt =
activity "send-quote" retry (fun attempt ->
mailer.Send(
attempt.IdempotencyKey, request.Customer, total))
return { Total = total; Approved = true; Receipt = receipt }
})
retry: RetryPolicyFCQRS.Workflows.RetryCommon retry policies.
times: int -> RetryPolicyAt most `attempts` attempts, with backoff from one second to one minute.
Schedule: RetryScheduleThe wait between attempts.
FixedIntervalRe-send at a fixed interval.
System.TimeSpanRepresents a time interval.
FromMilliseconds: float -> TimeSpanReturns a that represents a specified number of milliseconds. A number of milliseconds. is less than TimeSpan.MinValue or greater than TimeSpan.MaxValue. -or- is . -or- is . is equal to . An object that represents .
definition: IPriceService -> IMailer -> WorkflowDefinition<QuoteRequest,Quote>prices: IPriceServiceFcqrs_550-how-to_008-write-a-workflow.md_page.Quote.IPriceServicemailer: IMailerFcqrs_550-how-to_008-write-a-workflow.md_page.Quote.IMailerFCQRS.Workflows.WorkflowDefines workflows.
define: string -> ('Input -> Workflow<'Result>) -> WorkflowDefinition<'Input,'Result>A workflow named `name` whose instances run `body` on their input.
request: QuoteRequestFcqrs_550-how-to_008-write-a-workflow.md_page.Quote.QuoteRequestworkflow: WorkflowBuilderBuilds a workflow: `workflow { let! x = activity ... }`.
price: decimalactivity: 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.
attempt: AttemptContextFetch: string * string -> Task<decimal>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.
Symbol: stringtotal: 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
Quantity: intapproved: 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
FromDays: float -> TimeSpanReturns a that represents a specified number of days, where the specification is accurate to the nearest millisecond. A number of days, 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 .
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
Total: decimalApproved: boolReceipt: stringreceipt: stringSend: string * string * decimal -> Task<string>Customer: stringpublic sealed class QuoteWorkflow(IPriceService prices, IMailer mailer)
: Workflow<QuoteRequest, Quote>("quote")
{
// Five attempts, 200 ms apart.
private static readonly RetryPolicy Policy = Retry.times(5)
.WithSchedule(RetrySchedules.Fixed(TimeSpan.FromMilliseconds(200)));
public override async WorkflowTask<Quote> Run(
WorkflowContext ctx, QuoteRequest request)
{
// The price is stored once it arrives. A restart after this step
// does not call the price service again.
var price = await ctx.Activity("price", Policy, attempt =>
prices.Fetch(attempt.IdempotencyKey, request.Symbol));
var total = price * request.Quantity;
// A quote over 1000 waits for a person, for at most one day.
var approved = true;
if (total > 1000m)
{
var answer = await ctx.WaitSignal<bool>(
"approval", TimeSpan.FromDays(1));
approved = answer.Received && answer.Value;
}
if (!approved)
return new Quote(total, false, "");
var receipt = await ctx.Activity("send-quote", Policy, attempt =>
mailer.Send(attempt.IdempotencyKey, request.Customer, total));
return new Quote(total, true, receipt);
}
}
The workflow has three steps: price, the wait for approval, and send-quote. Their names are
stored. Renaming a step that a running instance has passed parks that instance; see
Change a workflow while instances are running.
The code between steps must give the same result every time. Computing total from the stored
price is fine. Reading the clock or calling a service there is not: do that in a step.
Register the workflow
Shared setup
open System
open Microsoft.Extensions.Configuration
open Microsoft.Extensions.Logging
open FCQRS.Actor
open FCQRS.FSharp
open FCQRS.Workflows
open Quote
let logging = LoggerFactory.Create(fun _ -> ())
let configuration = ConfigurationBuilder().Build()
let connection = Fcqrs.connect DBType.Sqlite "Data Source=quotes.db"
let api = Fcqrs.actor configuration logging (Some connection) "quotes"
let prices =
{ new IPriceService with
member _.Fetch(_, _) = task { return 12.5m } }
let mailer =
{ new IMailer with
member _.Send(_, _, _) = task { return "sent" } }
SystemMicrosoftExtensionsConfigurationLoggingFCQRSFCQRS.ActorFCQRS.FSharpIdiomatic-F# functional facade for FCQRS. Gives F# consumers the same one-call ergonomics the C# host-builder (HostExtensions.fs) gives C#, but with F# idioms: records-of-functions for the definitions, typed handles for the results, an explicit wiring pipeline, and plain helpers for saga side effects. It is a *pure addition* that wraps only the existing primitives (IActor.InitializeActor / SagaBuilder.initSimple / Projections.startTracked / InitializeSagaStarter / CreateCommandSubscription / Actor.api) and changes nothing in the C# interop layer or the core. open FCQRS.FSharp let api = Fcqrs.actor config loggerFactory (Some (Fcqrs.connect DBType.Sqlite conn)) "Cluster" let documents = Fcqrs.aggregate api { Name="Document"; Initial=...; Decide=...; Fold=... } let slugs = Fcqrs.aggregate api { Name="Slug"; Initial=...; Decide=...; Fold=... } let publication = Fcqrs.saga api (publicationDef documents.Factory slugs.Factory) Fcqrs.wireSagaStarters api [ publication ] let subs = Fcqrs.projection api (Projection.single 0 updateReadModel) // (Projection.multi when you must control which notifications publish) // send a command and await the matching aggregate reply: let! ev = documents.Send (Fcqrs.newCid()) (Fcqrs.aggregateId id) cmd (fun e -> ...)
WorkflowsFcqrs_550-how-to_008-write-a-workflow.md_page.Quotelogging: ILoggerFactoryMicrosoft.Extensions.Logging.LoggerFactoryProduces instances of classes based on the given providers.
Create: Action<ILoggingBuilder> -> ILoggerFactoryCreates new instance of configured using provided delegate. A delegate to configure the . The that was created.
configuration: IConfigurationRoot``.ctor``: unit -> unitBuild: unit -> IConfigurationRootBuilds an with keys and values from the set of providers registered in . An with keys and values from the registered providers.
connection: ConnectionFCQRS.FSharp.Fcqrsconnect: DBType -> string -> ConnectionBuild a SQLite/etc. Connection from a raw connection string of any length.
FCQRS.Actor.DBTypeRepresents the type of database connection
SqliteSQLite using Microsoft.Data.Sqlite provider
api: FCQRS.Common.IActoractor: IConfiguration -> ILoggerFactory -> Connection option -> string -> FCQRS.Common.IActorCreate the actor system from plain values (cluster name as a string).
SomeThe representation of "Value of type 'T" The input value. An option representing the value.
prices: IPriceServiceFcqrs_550-how-to_008-write-a-workflow.md_page.Quote.IPriceService_: IPriceServiceFetch: string * string -> Threading.Tasks.Task<decimal>task: TaskBuilderBuilds a task using computation expression syntax.
mailer: IMailerFcqrs_550-how-to_008-write-a-workflow.md_page.Quote.IMailer_: IMailerSend: string * string * decimal -> Threading.Tasks.Task<string>// Registering the workflow also resumes the instances that were running when
// the process last stopped.
let quotes = Workflows.register api (definition prices mailer)
quotes: WorkflowHandle<QuoteRequest,Quote>FCQRS.Workflows.WorkflowsRegisters workflows with an actor system.
register: FCQRS.Common.IActor -> WorkflowDefinition<'Input,'Result> -> WorkflowHandle<'Input,'Result>Registers the workflow and returns its handle. Instances that were running when the process last stopped resume as soon as this is called.
api: FCQRS.Common.IActordefinition: IPriceService -> IMailer -> WorkflowDefinition<QuoteRequest,Quote>prices: IPriceServicemailer: IMailer// Registering the workflow also resumes the instances that were running when
// the process last stopped.
var quotes = WorkflowHost.Register(actor, new QuoteWorkflow(prices, mailer));
Register every workflow when the process starts, before it serves requests. An instance that was
running when the process stopped resumes only once its workflow is registered. In a C# host,
services.AddWorkflow<QuoteWorkflow, QuoteRequest, Quote>() after AddFcqrs does this at startup
and provides IWorkflowClient<QuoteRequest, Quote>.
Start an instance, signal it, and read its result
let id = "q-1001"
let request = { Customer = "Ada"; Symbol = "ABC"; Quantity = 100 }
match quotes.Start id request |> Async.RunSynchronously with
| StartResult.Started ->
printfn "started %s" id
// The quote is over 1000, so the workflow waits for this signal.
Workflows.signal quotes id "approval" true |> Async.RunSynchronously
| StartResult.AlreadyStarted status ->
printfn "%s is already in the journal: %A" id status
// Waits for the workflow to end. The workflow does not depend on this wait.
let outcome =
quotes.Result id (TimeSpan.FromSeconds 30.0) |> Async.RunSynchronously
id: stringrequest: QuoteRequestCustomer: stringSymbol: stringQuantity: intquotes: WorkflowHandle<QuoteRequest,Quote>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.
FCQRS.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`.
AlreadyStartedA workflow with this ID already exists. Nothing was stored.
status: WorkflowStatusoutcome: WorkflowOutcome<Quote>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 .
var id = "q-1001";
var request = new QuoteRequest("Ada", "ABC", 100);
if (await quotes.StartAsync(id, request))
{
Console.WriteLine($"started {id}");
// The quote is over 1000, so the workflow waits for this signal.
await quotes.SignalAsync(id, "approval", true);
}
else
{
var status = await quotes.StatusAsync(id);
Console.WriteLine($"{id} is already in the journal: {status}");
}
// Waits for the workflow to end. The workflow does not depend on this wait.
var quote = await quotes.ResultAsync(id, TimeSpan.FromSeconds(30));
- The ID is yours to choose. A second start with the same ID stores nothing and reports the existing instance. Compute the ID from the request, such as an order number, so a caller that retries does not start a second workflow.
- Start returns once the start is stored. From then on the instance runs whether or not anyone waits for it.
- The signal is stored before the call returns. It can be sent before the workflow reaches its wait.
- Waiting for the result is optional. The wait is in memory. If the caller gives up, the workflow carries on, and a later call reads the stored result.
Stop the process in the middle
Run the sample with --crash. The mailer stops the process inside the last step:
started q-1001
price service called for ABC (key quote/q-1001/0)
price service called for ABC (key quote/q-1001/0)
price service called for ABC (key quote/q-1001/0)
mailer called (key quote/q-1001/2)
the process stops before this step is stored
The price service failed twice and answered on the third attempt. All three attempts carried the same key.
Run it again without the flag:
mailer called (key quote/q-1001/2)
q-1001 is already in the journal: Running ["2 send-quote"]
finished: quote of 1250.0 sent to Ada (approved: true)
price service calls in this run: 0
- The instance resumed when its workflow was registered, before the program asked for it.
- The price and the approval were in the journal, so neither step ran again.
send-quotehad no stored result, so it ran again, with the same key as before. A mailer that remembers keys would send one message.
What this recipe leaves to you
- The stand-in services ignore the key. A real one must use it.
- A quote whose approval never arrives waits one day and ends unapproved. Choose that time, and the outcome, for your business.
- If
send-quotefails five times, the workflow faults and stays faulted. CatchWorkflowStepFailedExceptionin the body where a failure should lead somewhere else.
Next: Observe your system lists the spans and metrics a workflow emits.