Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Revise Cart Command handling semantics #211

Merged
merged 3 commits into from
Mar 26, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ The `Unreleased` section name is replaced by the expected version of next releas
- Target `FSharp.Control.AsyncSeq` v `2.0.23`
- Updated AzDO CI/CD to use `windows-latest`
- Remove `module Commands` convention from in examples
- Revise semantics of Cart Sample Command handling

### Removed
### Fixed
Expand Down
96 changes: 62 additions & 34 deletions samples/Store/Domain.Tests/CartTests.fs
Original file line number Diff line number Diff line change
@@ -1,73 +1,92 @@
module Samples.Store.Domain.Tests.CartTests

open Domain
open Domain.Cart
open Domain.Cart.Events
open Domain.Cart.Fold
open Swensen.Unquote
open TypeShape.Empty

let mkAddQty skuId qty = ItemAdded { empty<ItemAddInfo> with skuId = skuId; quantity = qty }
let mkAdd skuId = mkAddQty skuId 1
let mkRemove skuId = ItemRemoved { empty<ItemRemoveInfo> with skuId = skuId }
let mkChangeWaived skuId value = ItemWaiveReturnsChanged { empty<ItemWaiveReturnsInfo> with skuId = skuId; waived = value }
let mkAddQty skuId qty waive = Events.ItemAdded { empty<Events.ItemAddedInfo> with skuId = skuId; quantity = qty; waived = waive }
let mkAdd skuId = mkAddQty skuId 1 None
let mkRemove skuId = Events.ItemRemoved { empty<Events.ItemRemovedInfo> with skuId = skuId }
let mkChangeWaived skuId value = Events.ItemPropertiesChanged { empty<Events.ItemPropertiesChangedInfo> with skuId = skuId; waived = value }

/// Represents the high level primitives that can be expressed in a SyncItem Command
type Command =
| AddItem of Context * SkuId * quantity : int * waiveStatus : bool option
| PatchItem of Context * SkuId * quantity : int option * waiveStatus : bool option
| RemoveItem of Context * SkuId

let interpret = function
| AddItem (c, s, q, w) -> SyncItem (c, s, Some q, w) |> interpret
| PatchItem (c, s, q, w) -> SyncItem (c, s, q, w) |> interpret
| RemoveItem (c, s) -> SyncItem (c, s, Some 0, None) |> interpret

/// As a basic sanity check, verify the basic properties we'd expect per command if we were to run it on an empty stream
// Note validating basics like this is not normally that useful a property; in this instance (I think) it takes some
// cases/logic out of the main property and is hence worth doing for this aggregate
let verifyCanProcessInInitialState cmd (originState: State) =
let verifyCanProcessInOriginState cmd (originState: State) =
let events = interpret cmd originState
match cmd with
| AddItem _ ->
test <@ (not << List.isEmpty) events @>
| PatchItem _
| PatchItem (_, _, Some 0, _)
| RemoveItem _ ->
test <@ List.isEmpty events @>
| _ ->
test <@ (not << List.isEmpty) events @>

/// Put the aggregate into the state where the command should trigger an event; verify correct events are yielded
let verifyCorrectEventGenerationWhenAppropriate command (originState: State) =
let initialEvents = command |> function
| AddItem _ -> []
| AddItem _
| PatchItem (_, _, None, _) -> []
| RemoveItem (_, skuId)
| PatchItem (_, skuId, Some 0, _) -> [ mkAdd skuId ]
| PatchItem (_, skuId, quantity, None) -> [ mkAddQty skuId (defaultArg quantity 0+1) ]
| PatchItem (_, skuId, quantity, Some waive) ->
[ mkAddQty skuId (defaultArg quantity 0+1)
mkChangeWaived skuId (not waive)]
| PatchItem (_, skuId, Some 0, _) -> [ mkAdd skuId ]
| PatchItem (_, skuId, Some quantity, Some waive) ->
[ mkAddQty skuId (quantity+1) (Some (not waive)) ]
| PatchItem (_, skuId, Some quantity, waive) -> [ mkAddQty skuId (quantity+1) waive]
let state = fold originState initialEvents
let events = interpret command state
let state' = fold state events

let find skuId = state'.items |> List.find (fun x -> x.skuId = skuId)
let find skuId = state'.items |> List.find (fun x -> x.skuId = skuId)

match command, events with
| AddItem (_, csku, quantity), [ ItemAdded e ] ->
test <@ { ItemAddInfo.context = e.context; skuId = csku; quantity = quantity } = e
| AddItem (_, csku, quantity, waive), [ Events.ItemAdded e ] ->
test <@ e = { context = e.context; skuId = csku; quantity = quantity; waived = waive }
&& quantity = (find csku).quantity @>
| PatchItem (_, csku, Some 0, _), [ ItemRemoved e ]
| RemoveItem (_, csku), [ ItemRemoved e ] ->
test <@ { ItemRemoveInfo.context = e.context; skuId = csku } = e
| PatchItem (_, csku, Some 0, _), [ Events.ItemRemoved e ]
| RemoveItem (_, csku), [ Events.ItemRemoved e ] ->
test <@ e = { Events.ItemRemovedInfo.context = e.context; skuId = csku }
&& not (state'.items |> List.exists (fun x -> x.skuId = csku)) @>
| PatchItem (_, csku, quantity, waive), es ->
match quantity with
| Some value ->
test <@ es |> List.exists (function ItemQuantityChanged e -> e = { context = e.context; skuId = csku; quantity = value } | _ -> false)
test <@ es
|> List.exists (function
| Events.ItemQuantityChanged e -> e = { context = e.context; skuId = csku; quantity = value }
| _ -> false)
&& value = (find csku).quantity @>
| None -> ()
match waive with
| None -> ()
| Some value ->
test <@ es |> List.exists (function ItemWaiveReturnsChanged e -> e = { context = e.context; skuId = csku; waived = value } | _ -> false)
&& value = (find csku).returnsWaived @>
test <@ es
|> List.exists (function
| Events.ItemPropertiesChanged e -> e = { context = e.context; skuId = csku; waived = value }
| _ -> false)
&& value = (find csku).returnsWaived.Value @>
| c,e -> failwithf "Invalid result - Command %A yielded Events %A in State %A" c e state

/// Processing should allow for any given Command to be retried at will
/// Processing should allow for any given Command to be retried at will, without inducing redundant
/// (and hence potentially-conflicting) changes
let verifyIdempotency (cmd: Command) (originState: State) =
// Put the aggregate into the state where the command should not trigger an event
let establish: Event list = cmd |> function
| AddItem (_, skuId, qty) -> [ mkAddQty skuId qty]
let establish: Events.Event list = cmd |> function
| AddItem (_, skuId, qty, waive) -> [ mkAddQty skuId qty waive]
| RemoveItem _
| PatchItem (_, _, Some 0, _) -> []
| PatchItem (_, skuId, quantity, waived) -> [ mkAddQty skuId (defaultArg quantity 1)
mkChangeWaived skuId (defaultArg waived false) ]
| PatchItem (_, _, Some 0, _)
| PatchItem (_, _, None, _) -> []
| PatchItem (_, skuId, Some quantity, waived) ->[ mkAddQty skuId quantity waived ]
let state = fold originState establish
let events = interpret cmd state

Expand All @@ -76,13 +95,22 @@ let verifyIdempotency (cmd: Command) (originState: State) =

/// These cases are assumed to be covered by external validation, so logic can treat them as hypotheticals rather than have to reject
let isValid = function
| PatchItem (_, _, Some quantity, _)
| AddItem (_, _, quantity) -> quantity >= 0
// One can generate a null request consisting of quantity = None, waived = None, which has no concievable outcome
// we don't guard or special case this condition
| PatchItem (_, _, None, _) -> false
| AddItem (_, _, quantity, _)
| PatchItem (_, _, Some quantity, _) -> quantity > 0
| _ -> true

// For the origin state, we only do basic filtering, which can provide good fuzz testing even if our implementation
// might not happen to ever trigger such a state (as opposed to neutering an entire scenario as we do with isValue)
let (|ValidOriginState|) : Fold.State -> Fold.State =
let updateItems f = function { items = i } -> { items = f i }
updateItems (List.choose (function { quantity = q } as x when q > 0 -> Some x | _ -> None))

[<DomainProperty>]
let ``interpret yields correct events, idempotently`` (cmd: Command) (originState: State) =
let ``interpret yields correct events, idempotently`` (cmd: Command) (ValidOriginState originState) =
if not (isValid cmd) then () else
verifyCanProcessInInitialState cmd originState
verifyCanProcessInOriginState cmd originState
verifyCorrectEventGenerationWhenAppropriate cmd originState
verifyIdempotency cmd originState
90 changes: 50 additions & 40 deletions samples/Store/Domain/Cart.fs
Original file line number Diff line number Diff line change
Expand Up @@ -3,32 +3,32 @@
let streamName (id: CartId) = FsCodec.StreamName.create "Cart" (CartId.toString id)

// NOTE - these types and the union case names reflect the actual storage formats and hence need to be versioned with care
[<RequireQualifiedAccess>]
module Events =

type ContextInfo = { time: System.DateTime; requestId: RequestId }

type ItemInfo = { context: ContextInfo; item: ItemInfo }
type ItemAddInfo = { context: ContextInfo; skuId: SkuId; quantity: int }
type ItemRemoveInfo = { context: ContextInfo; skuId: SkuId }
type ItemQuantityChangeInfo = { context: ContextInfo; skuId: SkuId; quantity: int }
type ItemWaiveReturnsInfo = { context: ContextInfo; skuId: SkuId; waived: bool }
type ItemAddedInfo = { context: ContextInfo; skuId: SkuId; quantity: int; waived: bool option }
type ItemRemovedInfo = { context: ContextInfo; skuId: SkuId }
type ItemQuantityChangedInfo = { context: ContextInfo; skuId: SkuId; quantity: int }
type ItemPropertiesChangedInfo ={ context: ContextInfo; skuId: SkuId; waived: bool }

module Compaction =
type StateItemInfo = { skuId: SkuId; quantity: int; returnsWaived: bool }
type StateItemInfo = { skuId: SkuId; quantity: int; returnsWaived: bool option }
type State = { items: StateItemInfo[] }

type Event =
| Snapshotted of Compaction.State
| ItemAdded of ItemAddInfo
| ItemRemoved of ItemRemoveInfo
| ItemQuantityChanged of ItemQuantityChangeInfo
| ItemWaiveReturnsChanged of ItemWaiveReturnsInfo
| ItemAdded of ItemAddedInfo
| ItemRemoved of ItemRemovedInfo
| ItemQuantityChanged of ItemQuantityChangedInfo
| ItemPropertiesChanged of ItemPropertiesChangedInfo
interface TypeShape.UnionContract.IUnionContract
let codec = FsCodec.NewtonsoftJson.Codec.Create<Event>()

module Fold =

type ItemInfo = { skuId: SkuId; quantity: int; returnsWaived: bool }
type ItemInfo = { skuId: SkuId; quantity: int; returnsWaived: bool option }
type State = { items: ItemInfo list }
module State =
let toSnapshot (s: State) : Events.Compaction.State =
Expand All @@ -39,44 +39,54 @@ module Fold =
let evolve (state : State) event =
let updateItems f = { state with items = f state.items }
match event with
| Events.Snapshotted s -> State.ofSnapshot s
| Events.ItemAdded e -> updateItems (fun current -> { skuId = e.skuId; quantity = e.quantity; returnsWaived = false } :: current)
| Events.ItemRemoved e -> updateItems (List.filter (fun x -> x.skuId <> e.skuId))
| Events.ItemQuantityChanged e -> updateItems (List.map (function i when i.skuId = e.skuId -> { i with quantity = e.quantity } | i -> i))
| Events.ItemWaiveReturnsChanged e -> updateItems (List.map (function i when i.skuId = e.skuId -> { i with returnsWaived = e.waived } | i -> i))
| Events.Snapshotted s ->
State.ofSnapshot s
| Events.ItemAdded e ->
updateItems (fun current ->
{ skuId = e.skuId; quantity = e.quantity; returnsWaived = e.waived }
:: current)
| Events.ItemRemoved e ->
updateItems (List.filter (fun x -> x.skuId <> e.skuId))
| Events.ItemQuantityChanged e ->
updateItems (List.map (function
| i when i.skuId = e.skuId -> { i with quantity = e.quantity }
| i -> i))
| Events.ItemPropertiesChanged e ->
updateItems (List.map (function
| i when i.skuId = e.skuId -> { i with returnsWaived = Some e.waived }
| i -> i))
let fold : State -> Events.Event seq -> State = Seq.fold evolve
let isOrigin = function Events.Snapshotted _ -> true | _ -> false
let snapshot = State.toSnapshot >> Events.Snapshotted

type Context = { time: System.DateTime; requestId : RequestId }
type Command =
| AddItem of Context * SkuId * quantity: int
| PatchItem of Context * SkuId * quantity: int option * waived: bool option
| RemoveItem of Context * SkuId
| SyncItem of Context * SkuId * quantity: int option * waived: bool option

let interpret command (state : Fold.State) =
let itemExists f = state.items |> List.exists f
let itemExistsWithDifferentWaiveStatus skuId waive = itemExists (fun x -> x.skuId = skuId && x.returnsWaived <> waive)
let itemExistsWithDifferentWaiveStatus skuId waive = itemExists (fun x -> x.skuId = skuId && x.returnsWaived <> Some waive)
let itemExistsWithDifferentQuantity skuId quantity = itemExists (fun x -> x.skuId = skuId && x.quantity <> quantity)
let itemExistsWithSameQuantity skuId quantity = itemExists (fun x -> x.skuId = skuId && x.quantity = quantity)
let itemExistsWithSkuId skuId = itemExists (fun x -> x.skuId = skuId && x.quantity <> 0)
let itemExistsWithSkuId skuId = itemExists (fun x -> x.skuId = skuId)
let toEventContext (reqContext: Context) = { requestId = reqContext.requestId; time = reqContext.time } : Events.ContextInfo
let (|Context|) (context : Context) = toEventContext context
let maybePropChanges c skuId = function
| None -> []
| Some waived ->
if not (itemExistsWithDifferentWaiveStatus skuId waived) then []
else [ Events.ItemPropertiesChanged { context = c; skuId = skuId; waived = waived } ]
let maybeQuantityChanges c skuId quantity =
if not (itemExistsWithDifferentQuantity skuId quantity) then [] else
[ Events.ItemQuantityChanged { context = c; skuId = skuId; quantity = quantity } ]
match command with
| AddItem (Context c, skuId, quantity) ->
if itemExistsWithSameQuantity skuId quantity then [] else
[ Events.ItemAdded { context = c; skuId = skuId; quantity = quantity } ]
| RemoveItem (Context c, skuId)
| PatchItem (Context c, skuId, Some 0, _) ->
if not (itemExistsWithSkuId skuId) then [] else
[ Events.ItemRemoved { context = c; skuId = skuId } ]
| PatchItem (_, skuId, _, _) when not (itemExistsWithSkuId skuId) ->
[]
| PatchItem (Context c, skuId, quantity, waived) ->
[ match quantity with
| Some quantity when itemExistsWithDifferentQuantity skuId quantity ->
yield Events.ItemQuantityChanged { context = c; skuId = skuId; quantity = quantity }
| _ -> ()
match waived with
| Some waived when itemExistsWithDifferentWaiveStatus skuId waived ->
yield Events.ItemWaiveReturnsChanged { context = c; skuId = skuId; waived = waived }
| _ -> () ]
// a request to set quantity of `0` represents a removal request
| SyncItem (Context c, skuId, Some 0, _) ->
if itemExistsWithSkuId skuId then [ Events.ItemRemoved { context = c; skuId = skuId } ]
else []
// Add/quantity change with potential waive change at same time
| SyncItem (Context c, skuId, Some q, w) ->
if itemExistsWithSkuId skuId then maybeQuantityChanges c skuId q @ maybePropChanges c skuId w
else [ Events.ItemAdded { context = c; skuId = skuId; quantity = q; waived = w } ]
// Waive return status change only
| SyncItem (Context c, skuId, None, w) ->
maybePropChanges c skuId w
4 changes: 2 additions & 2 deletions samples/Store/Integration/CartIntegration.fs
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,9 @@ let resolveCosmosStreamWithoutCustomAccessStrategy gateway =
let addAndThenRemoveItemsManyTimesExceptTheLastOne context cartId skuId (service: Backend.Cart.Service) count =
service.ExecuteManyAsync(cartId, false, seq {
for i in 1..count do
yield Domain.Cart.AddItem (context, skuId, i)
yield Domain.Cart.SyncItem (context, skuId, Some i, None)
if i <> count then
yield Domain.Cart.RemoveItem (context, skuId) })
yield Domain.Cart.SyncItem (context, skuId, Some 0, None) })

type Tests(testOutputHelper) =
let testOutput = TestOutputAdapter testOutputHelper
Expand Down
8 changes: 4 additions & 4 deletions tests/Equinox.Cosmos.Integration/CosmosIntegration.fs
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,9 @@ type Tests(testOutputHelper) =
let addAndThenRemoveItems optimistic exceptTheLastOne context cartId skuId (service: Backend.Cart.Service) count =
service.ExecuteManyAsync(cartId, optimistic, seq {
for i in 1..count do
yield Domain.Cart.AddItem (context, skuId, i)
yield Domain.Cart.SyncItem (context, skuId, Some i, None)
if not exceptTheLastOne || i <> count then
yield Domain.Cart.RemoveItem (context, skuId) })
yield Domain.Cart.SyncItem (context, skuId, Some 0, None) })
let addAndThenRemoveItemsManyTimes context cartId skuId service count =
addAndThenRemoveItems false false context cartId skuId service count
let addAndThenRemoveItemsManyTimesExceptTheLastOne context cartId skuId service count =
Expand Down Expand Up @@ -129,7 +129,7 @@ type Tests(testOutputHelper) =
return Some (skuId, addRemoveCount) }

let act prepare (service : Backend.Cart.Service) skuId count =
service.ExecuteManyAsync(cartId, false, prepare = prepare, commands = [Domain.Cart.AddItem (context, skuId, count)])
service.ExecuteManyAsync(cartId, false, prepare = prepare, commands = [Domain.Cart.SyncItem (context, skuId, Some count, None)])

let eventWaitSet () = let e = new ManualResetEvent(false) in (Async.AwaitWaitHandle e |> Async.Ignore), async { e.Set() |> ignore }
let w0, s0 = eventWaitSet ()
Expand Down Expand Up @@ -258,7 +258,7 @@ type Tests(testOutputHelper) =
return Some (skuId, addRemoveCount) }

let act prepare (service : Backend.Cart.Service) skuId count =
service.ExecuteManyAsync(cartId, false, prepare = prepare, commands = [Domain.Cart.AddItem (context, skuId, count)])
service.ExecuteManyAsync(cartId, false, prepare = prepare, commands = [Domain.Cart.SyncItem (context, skuId, Some count, None)])

let eventWaitSet () = let e = new ManualResetEvent(false) in (Async.AwaitWaitHandle e |> Async.Ignore), async { e.Set() |> ignore }
let w0, s0 = eventWaitSet ()
Expand Down
Loading