Step 9: Guard the change
o-7 is parked: it started before the fraud check existed, and the new code asks it for a step it
never had. This step keeps both versions of the workflow in one body. Orders that passed the
change point keep the old steps, and new orders take the new one. When the guarded code runs,
o-7 resumes.
This page changes the program from step 8. Keep orders.db from step 8.
Record which code an order runs
In Order.fs (Order.cs), replace the workflow. The fraud check is now guarded by a version
step:
Shared setup
module Account =
open FCQRS.Common
// What the program and the order workflow can ask an account to do.
type AccountCommand =
| Deposit of amount: decimal
| Debit of payment: string * amount: decimal
// What the account replies. Rejected is a reply only: it is never stored.
type AccountEvent =
| Deposited of amount: decimal
| Debited of payment: string * amount: decimal
| Rejected of reason: string
// What the account knows now. Payments holds the payments it has taken, so
// a repeated debit takes the money once.
type AccountState = { Balance: decimal; Payments: Set<string> }
// The state before the account's first event.
let initial = { Balance = 0m; Payments = Set.empty }
// Chooses what to do with a command, based on the current state.
let decide (command: Command<AccountCommand>) (state: AccountState) =
match command.CommandDetails with
| Deposit amount -> PersistEvent(Deposited amount)
// A repeated debit gets the first answer. It is checked before the
// balance, because the first debit has already lowered the balance.
| Debit(payment, amount) when state.Payments.Contains payment ->
DeferEvent(Debited(payment, amount))
| Debit(_, amount) when amount > state.Balance ->
DeferEvent(Rejected $"Insufficient funds: {state.Balance} available")
| Debit(payment, amount) -> PersistEvent(Debited(payment, amount))
// Applies one event. A rejection or a repeated reply changes nothing.
let fold (event: Event<AccountEvent>) (state: AccountState) =
match event.EventDetails with
| Deposited amount -> { state with Balance = state.Balance + amount }
| Debited(payment, _) when state.Payments.Contains payment -> state
| Debited(payment, amount) ->
{ state with
Balance = state.Balance - amount
Payments = state.Payments.Add payment
}
| Rejected _ -> state
module Order =
open System
open System.Threading.Tasks
open FCQRS.Common
open FCQRS.FSharp
open FCQRS.Workflows
open Account
// Unchanged from step 8.
type OrderRequest = {
Customer: string
Item: string
Quantity: int
UnitPrice: decimal
}
type OrderResult = {
Status: string
Reservation: string
Shipment: string
Confirmation: Guid
PlacedAt: DateTime
}
// The warehouse is an external system. The key is the same for every
// attempt of one step, so the warehouse can recognise a repeated call.
type IWarehouse =
abstract Reserve:
key: string * item: string * quantity: int -> Task<string>
abstract Release: key: string * reservation: string -> Task<string>
// The carrier is another external system. It returns a shipment ID.
type ICarrier =
abstract Ship:
key: string * reservation: string * item: string -> Task<string>
// A fraud check is a third external system. It answers whether an order
// may go ahead.
type IFraudCheck =
abstract Check:
key: string * customer: string * total: decimal -> Task<bool>
let reserveRetry = Retry.times 5
Fcqrs_350-workflow-tutorial_009-guard-the-change.md_page.AccountFCQRSFCQRS.CommonContains common types like Events and Commands Functionality for Write Side.
Fcqrs_350-workflow-tutorial_009-guard-the-change.md_page.Account.AccountCommandDepositamount: decimaldecimalAn abbreviation for the CLI type . Basic Types
Debitpayment: stringstringAn abbreviation for the CLI type . Basic Types
Fcqrs_350-workflow-tutorial_009-guard-the-change.md_page.Account.AccountEventDepositedDebitedRejectedreason: stringFcqrs_350-workflow-tutorial_009-guard-the-change.md_page.Account.AccountStateBalance: decimalPayments: Set<string>Microsoft.FSharp.Collections.FSharpSet`1Immutable sets based on binary trees, where elements are ordered by F# generic comparison. By default comparison is the F# structural comparison function or uses implementations of the IComparable interface on element values. See the module for further operations on sets. All members of this class are thread-safe and may be used concurrently from multiple threads.
initial: AccountStateMicrosoft.FSharp.Collections.SetModuleContains operations for working with values of type .
empty: Set<'T>The empty set for the type 'T. Set.empty<int> Evaluates to set [ ].
decide: Command<AccountCommand> -> AccountState -> EventAction<AccountEvent>command: Command<AccountCommand>FCQRS.Common.Command`1Represents a command to be processed by an aggregate actor. <typeparam name="'CommandDetails">The specific type of the command payload.</typeparam>
state: AccountStateCommandDetails: 'CommandDetailsThe specific details or payload of the command.
PersistEventPersist the event to the journal. The actor's state will be updated using the event handler *after* persistence succeeds.
Contains: string -> boolA useful shortcut for Set.contains. See the Set module for further operations on sets. The value to check. True if the set contains value. let set = Set.empty.Add(2).Add(3) printfn $"Does the set contain 1? {set.Contains(1)}" The sample evaluates to the following output: Does the set contain 1? false
DeferEventPublish and fold the event in the live actor without storing it or incrementing the persisted version. A deferred event does not start a saga; running sagas still receive it.
(>): 'T -> 'T -> boolStructural greater-than The first parameter. The second parameter. The result of the comparison. 5 > 1 // Evaluates to true 5 > 5 // Evaluates to false (1, "a") > (1, "z") // Evaluates to false
fold: Event<AccountEvent> -> AccountState -> AccountStateevent: Event<AccountEvent>FCQRS.Common.Event`1Represents an event generated by an aggregate actor as a result of processing a command. <typeparam name="'EventDetails">The specific type of the event payload.</typeparam>
EventDetails: 'EventDetailsThe specific details or payload of the event.
(-): ^T1 -> ^T2 -> ^T3Overloaded subtraction operator The first parameter. The second parameter. The result of the operation. 10 - 2 // Evaluates to 8
Add: string -> Set<string>A useful shortcut for Set.add. Note this operation produces a new set and does not mutate the original set. The new set will share many storage nodes with the original. See the Set module for further operations on sets. The value to add to the set. The result set. let set = Set.empty.Add(1).Add(1).Add(2) printfn $"The new set is: {set}" The sample evaluates to the following output: The new set is: set [1; 2]
Fcqrs_350-workflow-tutorial_009-guard-the-change.md_page.OrderSystemThreadingTasksFCQRS.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_350-workflow-tutorial_009-guard-the-change.md_page.Order.OrderRequestCustomer: stringItem: stringQuantity: intintAn abbreviation for the CLI type . Basic Types
UnitPrice: decimalFcqrs_350-workflow-tutorial_009-guard-the-change.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.
Fcqrs_350-workflow-tutorial_009-guard-the-change.md_page.Order.IWarehouseReserve: IWarehouse -> string * string * int -> Task<string>keyitemquantitySystem.Threading.Tasks.Task`1Represents an asynchronous operation that can return a value. The type of the result produced by this .
Release: IWarehouse -> string * string -> Task<string>reservationFcqrs_350-workflow-tutorial_009-guard-the-change.md_page.Order.ICarrierShip: ICarrier -> string * string * string -> Task<string>Fcqrs_350-workflow-tutorial_009-guard-the-change.md_page.Order.IFraudCheckCheck: IFraudCheck -> string * string * decimal -> Task<bool>customertotalboolAn abbreviation for the CLI type . Basic Types
reserveRetry: 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)
(accounts: AggregateHandle<AccountCommand, AccountEvent>)
(fraud: IFraudCheck)
=
Workflow.define "order" (fun (request: OrderRequest) ->
workflow {
try
// 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
let finish status shipment = {
Status = status
Reservation = reservation
Shipment = shipment
Confirmation = confirmation
PlacedAt = placedAt
}
// Gives the stock back when the order does not ship.
let release =
activity "release-stock" reserveRetry (fun attempt ->
warehouse.Release(attempt.IdempotencyKey, reservation))
let total = request.UnitPrice * decimal request.Quantity
// An order that had passed this point before the fraud check
// was added gets 1 and skips it. A new order gets 2. An order
// that fails the fraud check is not approved.
let! change = version "fraud-check" 2
let check (attempt: AttemptContext) =
fraud.Check(attempt.IdempotencyKey, request.Customer, total)
let! passed =
workflow {
if change < 2 then
return true
else
return! activity "check-fraud" (Retry.times 3) check
}
// An order over 1000 waits for a person to approve it, for at
// most one day. With no answer by then, the wait returns None.
let approvalWait = TimeSpan.FromDays 1.0
let! approved =
workflow {
if not passed then
return false
elif total <= 1000m then
return true
else
let! answer =
waitSignalFor<bool> "approval" approvalWait
return defaultArg answer false
}
if not approved then
let! _ = release
return finish "not approved" ""
else
// The confirmation number identifies the payment, so a
// repeated debit is recognised as the same payment.
let payment = string confirmation
let! reply =
send accounts request.Customer (Debit(payment, total))
match reply with
| Rejected _ ->
let! _ = release
return finish "payment refused" ""
| _ ->
// Packing takes two seconds. The timer's due time
// is stored, so a restart does not start the wait.
do! sleep (TimeSpan.FromSeconds 2.0)
let! shipment =
activity "ship" (Retry.times 3) (fun attempt ->
carrier.Ship(
attempt.IdempotencyKey,
reservation,
request.Item))
return finish "shipped" shipment
// When a step's policy gives up, the step fails, and the body
// sees WorkflowStepFailedException. Running out of stock is an
// answer the order can give; any other failure is not caught.
with :? WorkflowStepFailedException as failed
when failed.StepName = "reserve-stock" ->
return {
Status = "out of stock"
Reservation = ""
Shipment = ""
Confirmation = Guid.Empty
PlacedAt = DateTime.MinValue
}
})
definition: IWarehouse -> ICarrier -> AggregateHandle<AccountCommand,AccountEvent> -> IFraudCheck -> WorkflowDefinition<OrderRequest,OrderResult>warehouse: IWarehouseFcqrs_350-workflow-tutorial_009-guard-the-change.md_page.Order.IWarehousecarrier: ICarrierFcqrs_350-workflow-tutorial_009-guard-the-change.md_page.Order.ICarrieraccounts: AggregateHandle<AccountCommand,AccountEvent>FCQRS.FSharp.AggregateHandle`2What you get back after registering an aggregate.
Fcqrs_350-workflow-tutorial_009-guard-the-change.md_page.Account.AccountCommandFcqrs_350-workflow-tutorial_009-guard-the-change.md_page.Account.AccountEventfraud: IFraudCheckFcqrs_350-workflow-tutorial_009-guard-the-change.md_page.Order.IFraudCheckFCQRS.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_009-guard-the-change.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.
finish: string -> string -> OrderResultstatus: stringshipment: stringStatus: stringReservation: stringShipment: stringConfirmation: GuidPlacedAt: DateTimerelease: Workflow<string>Release: string * string -> Task<string>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
change: intversion: string -> int -> Workflow<int>A value that records which version of the workflow's code an instance runs, for changing a workflow while instances are running: let! v = version "add-fraud-check" 2 if v >= 2 then ... new steps ... else ... the steps as they were ... A new instance stores `latest` and gets it. An instance that had already passed this point when the change was deployed gets 1 and stores nothing. The step takes no place in the step sequence, so adding it does not move the steps after it. `changeId` names the change. The number is stored under the step's position and this name. One name may appear at different positions, such as once per round of a loop, and different names may share a position. The same name twice at one position is not allowed. A later change at the same point keeps the name and raises `latest`. The name is a persistent contract: after a rename, an instance that stored a number finds none under the new name and gets 1.
check: AttemptContext -> Task<bool>FCQRS.Workflows.AttemptContextWhat an activity receives for one attempt.
Check: string * string * decimal -> Task<bool>Customer: stringpassed: bool(<): 'T -> 'T -> boolStructural less-than comparison The first parameter. The second parameter. The result of the comparison. 1 < 5 // Evaluates to true 5 < 5 // Evaluates to false (1, "a") < (1, "z") // Evaluates to true
FCQRS.Workflows.RetryCommon retry policies.
times: int -> RetryPolicyAt most `attempts` attempts, with backoff from one second to one minute.
approvalWait: TimeSpanSystem.TimeSpanRepresents a time interval.
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 .
approved: bool``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
(<=): '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
payment: stringstring: 'T -> stringConverts the argument to a string using ToString. For standard integer and floating point values and any type that implements IFormattable, ToString conversion uses CultureInfo.InvariantCulture. The input value. The converted string. string 'A' // evaluates to "A" string 0xff // evaluates to "255" string -10 // evaluates to "-10"
reply: AccountEventsend: AggregateHandle<'Command,'Event> -> string -> 'Command -> Workflow<'Event>`sendWith` under `Retry.command`: send again when no reply came in time, with backoff, for five minutes.
DebitRejectedsleep: TimeSpan -> Workflow<unit>Waits for `duration`. The due time is stored when the step is first reached, so a restart does not postpone it.
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 .
Ship: string * string * string -> Task<string>FCQRS.Workflows.WorkflowStepFailedExceptionRaised in a workflow body when a step ended without a value. The body sees this exception both when the step fails and when a stored failure is replayed, never the original exception.
failed: WorkflowStepFailedExceptionStepName: stringThe step's name.
(=): '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
System.GuidRepresents a globally unique identifier (GUID).
Empty: GuidA read-only instance of the structure whose value is all zeros.
System.DateTimeRepresents an instant in time, typically expressed as a date and time of day.
MinValue: DateTimeRepresents the smallest possible value of . This field is read-only.
// "order" is stored with every instance. It must not change.
public sealed class OrderWorkflow(
IWarehouse warehouse, ICarrier carrier, AggregateFactory accounts,
IFraudCheck fraud)
: 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)
{
try
{
return await Fulfil(ctx, request);
}
// When a step's policy gives up, the step fails, and the body sees
// WorkflowStepFailedException. Running out of stock is an answer
// the order can give; any other failure is not caught.
catch (WorkflowStepFailedException failed)
when (failed.StepName == "reserve-stock")
{
return new OrderResult(
"out of stock", "", "", Guid.Empty, DateTime.MinValue);
}
}
private async WorkflowTask<OrderResult> Fulfil(
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();
OrderResult Finish(string status, string shipment) =>
new(status, reservation, shipment, confirmation, placedAt);
// Gives the stock back when the order does not ship.
Step<string> Release() =>
ctx.Activity("release-stock", ReserveRetry, attempt =>
warehouse.Release(attempt.IdempotencyKey, reservation));
var total = request.UnitPrice * request.Quantity;
// An order that had passed this point before the fraud check was
// added gets 1 and skips it. A new order gets 2. An order that
// fails the fraud check is not approved.
var change = await ctx.Version("fraud-check", 2);
var passed = change < 2
|| await ctx.Activity("check-fraud", Retry.times(3),
attempt => fraud.Check(
attempt.IdempotencyKey, request.Customer, total));
// An order over 1000 waits for a person to approve it, for at
// most one day. With no answer by then, Received is false.
var approved = passed;
if (passed && total > 1000m)
{
var answer = await ctx.WaitSignal<bool>(
"approval", TimeSpan.FromDays(1));
approved = answer.Received && answer.Value;
}
if (!approved)
{
await Release();
return Finish("not approved", "");
}
// The confirmation number identifies the payment, so a repeated
// debit is recognised as the same payment.
var payment = confirmation.ToString();
var reply = await ctx.Send<AccountCommand, AccountEvent>(
accounts, request.Customer, new Debit(payment, total));
if (reply is Rejected)
{
await Release();
return Finish("payment refused", "");
}
// 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, request.Item));
return Finish("shipped", shipment);
}
}
version "fraud-check" 2 (ctx.Version in C#) answers which code this order runs:
- An order that reaches it for the first time stores 2 and gets 2. It runs the fraud check.
- An order whose journal already has a step after this point gets 1 and stores nothing. It takes
the old path, without the fraud check.
o-7stored its approval wait after this point, so it gets 1.
A version step takes no position, so the steps after it keep the positions they had. For o-7,
the approval is at position 3 again, where its journal has it.
Put a version step before the steps it guards and before at least one step that the old code
already had. A version step with no stored step after it, such as one added at the end of the
body, gives 2 to every running order.
Resume the parked order
In Program.fs (Program.cs), replace the lines after the functions. The program waits for
o-7, which needs no new signal, and places a new order:
Shared setup
open System
open System.Collections.Generic
open System.Threading
open Microsoft.Data.Sqlite
open FCQRS.Common
open FCQRS.FSharp
open FCQRS.Workflows
open Account
open Order
// Unchanged from step 8.
let database = "orders.db"
let api = Unchecked.defaultof<IActor>
type Warehouse() =
interface IWarehouse with
member _.Reserve(_, _, _) = Tasks.Task.FromResult "r-1"
member _.Release(_, reservation) = Tasks.Task.FromResult reservation
type Carrier(crash: bool) =
interface ICarrier with
member _.Ship(_, _, _) = Tasks.Task.FromResult "s-1"
let accounts =
Unchecked.defaultof<AggregateHandle<AccountCommand, AccountEvent>>
let orders = Unchecked.defaultof<WorkflowHandle<OrderRequest, OrderResult>>
SystemCollectionsGenericThreadingMicrosoftDataSqliteFCQRSFCQRS.CommonContains common types like Events and Commands Functionality for Write Side.
FCQRS.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_350-workflow-tutorial_009-guard-the-change.md_page.AccountFcqrs_350-workflow-tutorial_009-guard-the-change.md_page.Orderdatabase: stringapi: IActorMicrosoft.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.
FCQRS.Common.IActorDefines the core functionalities and context provided by the FCQRS environment to actors. This interface provides access to essential Akka.NET services and FCQRS initialization methods.
Fcqrs_350-workflow-tutorial_009-guard-the-change.md_page.WarehouseFcqrs_350-workflow-tutorial_009-guard-the-change.md_page.Order.IWarehouse_: WarehouseReserve: Warehouse -> string * string * int -> Tasks.Task<string>TasksFromResult: 'TResult -> Tasks.Task<'TResult>Creates a that's completed successfully with the specified result. The result to store into the completed task. The type of the result returned by the task. The successfully completed task.
System.Threading.Tasks.TaskRepresents an asynchronous operation.
Release: Warehouse -> string * string -> Tasks.Task<string>reservation: stringFcqrs_350-workflow-tutorial_009-guard-the-change.md_page.Carriercrash: boolboolAn abbreviation for the CLI type . Basic Types
Fcqrs_350-workflow-tutorial_009-guard-the-change.md_page.Order.ICarrier_: CarrierShip: Carrier -> string * string * string -> Tasks.Task<string>accounts: AggregateHandle<AccountCommand,AccountEvent>FCQRS.FSharp.AggregateHandle`2What you get back after registering an aggregate.
Fcqrs_350-workflow-tutorial_009-guard-the-change.md_page.Account.AccountCommandFcqrs_350-workflow-tutorial_009-guard-the-change.md_page.Account.AccountEventorders: WorkflowHandle<OrderRequest,OrderResult>FCQRS.Workflows.WorkflowHandle`2Starts and queries the instances of a registered workflow.
Fcqrs_350-workflow-tutorial_009-guard-the-change.md_page.Order.OrderRequestFcqrs_350-workflow-tutorial_009-guard-the-change.md_page.Order.OrderResult// Put money in a customer's account.
let deposit customer amount =
accounts.Send
(Fcqrs.newCid ()) (Fcqrs.aggregateId customer) (Deposit amount)
(fun _ -> true)
|> Async.RunSynchronously
|> ignore
printfn $"Deposited {amount} to {customer}"
// 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 customer item quantity approval =
let request = {
Customer = customer
Item = item
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}"
| WorkflowOutcome.Faulted error -> printfn $"{id} faulted: {error.Message}"
| other -> printfn $"{id} ended: {other}"
// o-7 runs again under this code. It skips the fraud check, uses the
// approval it got while it was parked, and ships.
let resumed =
orders.Result "o-7" (TimeSpan.FromSeconds 30.0) |> Async.RunSynchronously
match resumed with
| WorkflowOutcome.Finished result ->
printfn $"o-7 finished: shipped as {result.Shipment}"
| other -> printfn $"o-7 ended: {other}"
// A new order runs the fraud check.
place "o-9" "ada" "widget" 2 None
deposit: string -> decimal -> unitcustomer: stringamount: decimalaccounts: AggregateHandle<AccountCommand,AccountEvent>Send: FCQRS.Model.Data.CID -> FCQRS.Model.Data.AggregateId -> 'Command -> ('Event -> bool) -> Async<Event<'Event>>Send a command and await the first matching aggregate event. Fails with SagaAlreadyStartedException, with nothing stored, when the command would start a saga that its correlation ID already started on this aggregate.
FCQRS.FSharp.FcqrsnewCid: unit -> FCQRS.Model.Data.CIDA fresh correlation id (UUID v7).
aggregateId: string -> FCQRS.Model.Data.AggregateIdAn aggregate id from a string (e.g. a document/user key). Any non-blank id works: the shard names entity actors Uri.EscapeDataString(entityId), so characters Akka actor names would reject directly (spaces, %) are escaped before they reach an actor path.
Deposit(|>): '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 * 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.
ignore: 'T -> unitIgnore the passed value. This is often used to throw away results of a computation. The value to ignore. ignore 55555 // Evaluates to ()
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.
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.
FCQRS.Workflows.StartResultThe answer to a start request.
StartedThis request started the workflow.
AlreadyStartedA workflow with this ID already exists. Nothing was stored.
status: WorkflowStatusplace: string -> string -> string -> int -> 'a option -> unititem: stringquantity: 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
FaultedThe body threw an exception it did not handle.
error: StepErrorother: WorkflowOutcome<OrderResult>resumed: WorkflowOutcome<OrderResult>// Put money in a customer's account.
async Task Deposit(string customer, decimal amount)
{
await accounts.Handler(_ => true, Values.NewCID(),
Values.CreateAggregateId(customer), new Deposit(amount));
Console.WriteLine($"Deposited {amount} to {customer}");
}
// 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, string customer, string item, int quantity, bool? approval)
{
await Start(id, new OrderRequest(customer, item, quantity, 40m));
if (approval is bool approve)
await orders.SignalAsync(id, "approval", approve);
try
{
var result = await orders.ResultAsync(id, TimeSpan.FromSeconds(30));
Console.WriteLine(result.Status == "shipped"
? $"{id} finished: shipped as {result.Shipment}"
: $"{id} finished: {result.Status}");
}
catch (WorkflowFaultedException faulted)
{
Console.WriteLine($"{id} faulted: {faulted.Error.Message}");
}
}
// o-7 runs again under this code. It skips the fraud check, uses the
// approval it got while it was parked, and ships.
var resumed = await orders.ResultAsync("o-7", TimeSpan.FromSeconds(30));
Console.WriteLine($"o-7 finished: shipped as {resumed.Shipment}");
// A new order runs the fraud check.
await Place("o-9", "ada", "widget", 2, null);
Run it
Run dotnet run with step 8's orders.db in place:
carrier shipped r-2 (key order/o-7/6)
o-7 finished: shipped as s-1
Started o-9
warehouse busy (key order/o-9/0)
warehouse busy (key order/o-9/0)
warehouse reserved 2 widget (key order/o-9/0)
fraud check passed for ada (key order/o-9/3)
carrier shipped r-1 (key order/o-9/6)
o-9 finished: shipped as s-2
Journal of order/default-shard/o-7:
1 Started
2 StepScheduled
3 StepCompleted
4 StepCompleted
5 StepCompleted
6 StepScheduled
7 SignalReceived
8 StepCompleted
9 StepScheduled
10 StepCompleted
11 StepScheduled
12 StepCompleted
13 StepScheduled
14 StepCompleted
15 Finished
Registering the workflow resumed o-7 with the new code. Its journal matched: the version step
gave 1, the fraud check was skipped, and the approval wait at position 3 found the approval that
step 8 sent while the order was parked (record 7). Record 8 ends the wait. Then the payment
(records 9 and 10), the packing timer (11 and 12), and ship (13 and 14) ran, and the order
finished. ship is at position 6, as it would have been under step 7's code.
o-9 is new, so its version step gave 2, and it ran the fraud check at position 3.
Keep or remove the guard
The old path must stay in the body while an order that takes it is still running. An order that has ended does not run its body again: FCQRS reads its result from the journal. So once every order started before the change has ended, a later change can remove the old path.
A larger change, such as reordering many steps, is easier as a new workflow: register the new code under a new name, start new orders with it, and keep the old definition registered until its orders have ended.
Next
Step 10: Feed orders from a stream starts orders from an Akka.Streams flow, two at a time, and stops the process while two orders are shipping.