From 6abe2d5ce5e02b770b8dff0ded5211a7dcd4e866 Mon Sep 17 00:00:00 2001 From: Ruben Bartelink Date: Fri, 29 Nov 2019 16:49:32 +0000 Subject: [PATCH 01/51] Add Location initial impl --- equinox-fc/Domain.Tests/Domain.Tests.fsproj | 31 +++++++ equinox-fc/Domain.Tests/Infrastructure.fs | 50 +++++++++++ equinox-fc/Domain.Tests/LocationEpochTests.fs | 54 +++++++++++ .../Domain.Tests/LocationSeriesTests.fs | 44 +++++++++ equinox-fc/Domain.Tests/LocationTests.fs | 70 +++++++++++++++ equinox-fc/Domain/Domain.fsproj | 20 +++++ equinox-fc/Domain/Infrastructure.fs | 16 ++++ equinox-fc/Domain/Location.fs | 45 ++++++++++ equinox-fc/Domain/LocationEpoch.fs | 90 +++++++++++++++++++ equinox-fc/Domain/LocationSeries.fs | 52 +++++++++++ equinox-fc/Fc.sln | 48 ++++++++++ 11 files changed, 520 insertions(+) create mode 100644 equinox-fc/Domain.Tests/Domain.Tests.fsproj create mode 100644 equinox-fc/Domain.Tests/Infrastructure.fs create mode 100644 equinox-fc/Domain.Tests/LocationEpochTests.fs create mode 100644 equinox-fc/Domain.Tests/LocationSeriesTests.fs create mode 100644 equinox-fc/Domain.Tests/LocationTests.fs create mode 100644 equinox-fc/Domain/Domain.fsproj create mode 100644 equinox-fc/Domain/Infrastructure.fs create mode 100644 equinox-fc/Domain/Location.fs create mode 100644 equinox-fc/Domain/LocationEpoch.fs create mode 100644 equinox-fc/Domain/LocationSeries.fs create mode 100644 equinox-fc/Fc.sln diff --git a/equinox-fc/Domain.Tests/Domain.Tests.fsproj b/equinox-fc/Domain.Tests/Domain.Tests.fsproj new file mode 100644 index 000000000..8ec512e47 --- /dev/null +++ b/equinox-fc/Domain.Tests/Domain.Tests.fsproj @@ -0,0 +1,31 @@ + + + + netcoreapp2.1 + 5 + Fc.Domain.Tests + false + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/equinox-fc/Domain.Tests/Infrastructure.fs b/equinox-fc/Domain.Tests/Infrastructure.fs new file mode 100644 index 000000000..80c23b5eb --- /dev/null +++ b/equinox-fc/Domain.Tests/Infrastructure.fs @@ -0,0 +1,50 @@ +[] +module Infrastructure + +open Serilog +open System + +let (|Id|) (x : Guid) = x.ToString "N" |> FSharp.UMX.UMX.tag +let inline mkId () = Guid.NewGuid() |> (|Id|) +let (|Ids|) (xs : Guid[]) = xs |> Array.map (|Id|) +let (|IdsAtLeastOne|) (Id x, Ids xs) = Seq.append xs (Seq.singleton x) |> Seq.toArray + +module EnvVar = + + let tryGet k = Environment.GetEnvironmentVariable k |> Option.ofObj + +module Cosmos = + + open Equinox.Cosmos + let connect () = + match EnvVar.tryGet "EQUINOX_COSMOS_CONNECTION", EnvVar.tryGet "EQUINOX_COSMOS_DATABASE", EnvVar.tryGet "EQUINOX_COSMOS_CONTAINER" with + | Some s,Some d,Some c -> + let appName = "Domain.Tests" + let discovery = Discovery.FromConnectionString s + let connector = Connector(TimeSpan.FromSeconds 5., 1, TimeSpan.FromSeconds 5., Serilog.Log.Logger) + let connection = connector.Connect(appName,discovery) |> Async.RunSynchronously + let context = Context(connection,d,c) + let cache = Equinox.Cache (appName, 10) + context,cache + | s,d,c -> + failwithf "Connection, Database and Container EQUINOX_COSMOS_* Environment variables are required (%b,%b,%b)" + (Option.isSome s) (Option.isSome d) (Option.isSome c) + +/// Adapts the XUnit ITestOutputHelper to be a Serilog Sink +type TestOutputAdapter(testOutput : Xunit.Abstractions.ITestOutputHelper) = + let formatter = Serilog.Formatting.Display.MessageTemplateTextFormatter("{Timestamp:HH:mm:ss.fff zzz} [{Level:u3}] {Message}{Properties}{NewLine}{Exception}", null); + let writeSerilogEvent logEvent = + use writer = new System.IO.StringWriter() + formatter.Format(logEvent, writer) + let messageLine = string writer + testOutput.WriteLine messageLine + System.Diagnostics.Debug.Write messageLine + interface Serilog.Core.ILogEventSink with member __.Emit logEvent = writeSerilogEvent logEvent + +/// Creates a Serilog Log chain emitting to the cited Sink (only) +let createLogger sink = + Serilog.LoggerConfiguration() +// .MinimumLevel.Debug() + .Destructure.FSharpTypes() + .WriteTo.Sink(sink) + .CreateLogger() \ No newline at end of file diff --git a/equinox-fc/Domain.Tests/LocationEpochTests.fs b/equinox-fc/Domain.Tests/LocationEpochTests.fs new file mode 100644 index 000000000..8a4e14a4a --- /dev/null +++ b/equinox-fc/Domain.Tests/LocationEpochTests.fs @@ -0,0 +1,54 @@ +module LocationEpochTests + +open FsCheck.Xunit +open Location.Epoch +open Swensen.Unquote + +let interpret delta _balance = + match delta with + | 0 -> (),[] + | delta -> (),[Events.Delta { value = delta }] + +let validateAndInterpret expectedBalance delta balance = + test <@ expectedBalance = balance @> + interpret delta balance + +let verifyDeltaEvent delta events = + let dEvents = events |> List.filter (function Events.Delta _ -> true | _ -> false) + test <@ interpret delta (Unchecked.defaultof<_>) = ((),dEvents) @> + +let [] properties carriedForward delta1 closeImmediately delta2 close = + + (* Starting with an empty stream, we'll need to supply the balance carried forward, optionally we apply a delta and potentially close *) + + let initialShouldClose _state = closeImmediately + let res,events = sync (Some carriedForward) (validateAndInterpret carriedForward delta1) initialShouldClose Folds.initial + let cfEvents events = events |> List.filter (function Events.CarriedForward _ -> true | _ -> false) + let closeEvents events = events |> List.filter (function Events.Closed -> true | _ -> false) + let state1 = Folds.fold Folds.initial events + let expectedBalance = carriedForward + delta1 + // Only expect closing if it was requested + let expectImmediateClose = closeImmediately + test <@ Option.isSome res.result + && expectedBalance = res.balance @> + test <@ [Events.CarriedForward { initial = carriedForward }] = cfEvents events + && (not expectImmediateClose || 1 = Seq.length (closeEvents events)) @> + verifyDeltaEvent delta1 events + + (* After initializing, validate we don't need to supply a carriedForward, and don't produce a CarriedForward event *) + + let shouldClose _state = close + let { isOpen = isOpen; result = worked; balance = bal },events = sync None (validateAndInterpret expectedBalance delta2) shouldClose state1 + let expectedBalance = if expectImmediateClose then expectedBalance else expectedBalance + delta2 + test <@ [] = cfEvents events + && (expectImmediateClose || not close || 1 = Seq.length (closeEvents events)) @> + test <@ (expectImmediateClose || close || isOpen) + && expectedBalance = bal @> + if not expectImmediateClose then + test <@ Option.isSome worked @> + verifyDeltaEvent delta2 events + +let [] ``codec can roundtrip`` event = + let ee = Events.codec.Encode(None,event) + let ie = FsCodec.Core.TimelineEvent.Create(0L, ee.EventType, ee.Data) + test <@ Some event = Events.codec.TryDecode ie @> \ No newline at end of file diff --git a/equinox-fc/Domain.Tests/LocationSeriesTests.fs b/equinox-fc/Domain.Tests/LocationSeriesTests.fs new file mode 100644 index 000000000..1bd09a28d --- /dev/null +++ b/equinox-fc/Domain.Tests/LocationSeriesTests.fs @@ -0,0 +1,44 @@ +module LocationSeriesTests + +open FsCheck.Xunit +open FSharp.UMX +open Swensen.Unquote +open Location.Series + +let [] properties c1 c2 = + let events = interpretActivateEpoch c1 Folds.initial + let state1 = Folds.fold Folds.initial events + let epoch0 = %0 + match c1, events, toActiveEpoch state1 with + // Started events are not written for < 0 + | n, [], activeEpoch when n < epoch0 -> + test <@ None = activeEpoch @> + // Any >=0 value should trigger a Started event, initially + | n, [Events.Started { epochId = ee }], Some activatedEpoch -> + test <@ n >= epoch0 && n = ee && n = activatedEpoch @> + // Nothing else should yield events + | _, l, _ -> + test <@ List.isEmpty l @> + + let events = interpretActivateEpoch c2 state1 + let state2 = Folds.fold state1 events + match toActiveEpoch state1, c2, events, toActiveEpoch state2 with + // Started events are not written for < 0 + | None, n, [], activeEpoch when n < epoch0 -> + test <@ None = activeEpoch @> + // Any >= 0 epochId should trigger a Started event if first command didnt do anything + | None, n, [Events.Started { epochId = ee }], Some activatedEpoch -> + let eEpoch = %ee + test <@ n >= epoch0 && n = eEpoch && n = activatedEpoch @> + // Any higher epochId should trigger a Started event (gaps are fine - we are only tying to reduce walks) + | Some s1, n, [Events.Started { epochId = ee }], Some activatedEpoch -> + let eEpoch = %ee + test <@ n > s1 && n = eEpoch && n > epoch0 && n = activatedEpoch @> + // Nothing else should yield events + | _, _, l, _ -> + test <@ List.isEmpty l @> + +let [] ``codec can roundtrip`` event = + let ee = Events.codec.Encode(None,event) + let ie = FsCodec.Core.TimelineEvent.Create(0L, ee.EventType, ee.Data) + test <@ Some event = Events.codec.TryDecode ie @> \ No newline at end of file diff --git a/equinox-fc/Domain.Tests/LocationTests.fs b/equinox-fc/Domain.Tests/LocationTests.fs new file mode 100644 index 000000000..2e117f65f --- /dev/null +++ b/equinox-fc/Domain.Tests/LocationTests.fs @@ -0,0 +1,70 @@ +module LocationTests + +open FsCheck.Xunit +open FSharp.UMX +open Location +open Swensen.Unquote +open System + +/// Helpers to match `module Cosmos` wrapping inside the impl +module Location = + + open Equinox.MemoryStore + + module Series = + + let resolve store = Resolver(store, Series.Events.codec, Series.Folds.fold, Series.Folds.initial).Resolve + + module Epoch = + + let resolve store = Resolver(store, Epoch.Events.codec, Epoch.Folds.fold, Epoch.Folds.initial).Resolve + + module MemoryStore = + + let createService (zeroBalance, shouldClose) store = + let maxAttempts = Int32.MaxValue + let series = Series.create (Series.resolve store) maxAttempts + let epochs = Epoch.create (Epoch.resolve store) maxAttempts + create (zeroBalance, shouldClose) (series, epochs) + +let run (service : LocationService) (IdsAtLeastOne locations, deltas : _[]) = Async.RunSynchronously <| async { + let runId = mkId () // Need to make making state in store unique when replaying or shrinking + let locations = locations |> Array.map (fun x -> % (sprintf "%O_%O" runId x)) + + let updates = deltas |> Seq.mapi (fun i x -> locations.[i % locations.Length], x) |> Seq.cache + + (* Apply random deltas *) + + let adjust delta (bal : Epoch.Folds.Balance) = + let value = max -bal delta + if value = 0 then 0, [] + else value, [Location.Epoch.Events.Delta { value = value }] + let! appliedDeltas = seq { for loc,x in updates -> async { let! _,eff = service.Execute(loc, adjust x) in return loc,eff } } |> Async.Parallel + let expectedBalances = Seq.append (seq { for l in locations -> l, 0}) appliedDeltas |> Seq.groupBy fst |> Seq.map (fun (l,xs) -> l, xs |> Seq.sumBy snd) |> Set.ofSeq + + (* Verify loading yields identical state *) + + let! balances = seq { for loc in locations -> async { let! bal,() = service.Execute(loc,(fun _ -> (),[])) in return loc,bal } } |> Async.Parallel + test <@ expectedBalances = Set.ofSeq balances @> } + +let [] ``MemoryStore properties`` maxEvents args = + let store = Equinox.MemoryStore.VolatileStore() + let zeroBalance = 0 + let maxEvents = max 1 maxEvents + let shouldClose (state : Epoch.Folds.OpenState) = state.count > maxEvents + let service = Location.MemoryStore.createService (zeroBalance, shouldClose) store + run service args + +type Cosmos(testOutput) = + + let context,cache = Cosmos.connect () + + let log = testOutput |> TestOutputAdapter |> createLogger + do Serilog.Log.Logger <- log + + let [] properties maxEvents args = + let zeroBalance = 0 + let maxEvents = max 1 maxEvents + let shouldClose (state : Epoch.Folds.OpenState) = state.count > maxEvents + let service = Location.Cosmos.createService (zeroBalance, shouldClose) (context,cache,Int32.MaxValue) + run service args \ No newline at end of file diff --git a/equinox-fc/Domain/Domain.fsproj b/equinox-fc/Domain/Domain.fsproj new file mode 100644 index 000000000..107408957 --- /dev/null +++ b/equinox-fc/Domain/Domain.fsproj @@ -0,0 +1,20 @@ + + + + netstandard2.0 + 5 + Fc.Domain + + + + + + + + + + + + + + diff --git a/equinox-fc/Domain/Infrastructure.fs b/equinox-fc/Domain/Infrastructure.fs new file mode 100644 index 000000000..7a1bfa843 --- /dev/null +++ b/equinox-fc/Domain/Infrastructure.fs @@ -0,0 +1,16 @@ +namespace global + +open FSharp.UMX // see https://github.com/fsprojects/FSharp.UMX - % operator and ability to apply units of measure to Guids+strings + +type LocationId = string +and [] locationId +module LocationId = + let parse (value : string) : LocationId = %value + let toString (value : LocationId) : string = %value + +type LocationEpochId = int +and [] locationEpochId +module LocationEpochId = + let parse (value : int) : LocationEpochId = %value + let next (value : LocationEpochId) : LocationEpochId = % (%value + 1) + let toString (value : LocationEpochId) : string = string %value \ No newline at end of file diff --git a/equinox-fc/Domain/Location.fs b/equinox-fc/Domain/Location.fs new file mode 100644 index 000000000..50511d8d3 --- /dev/null +++ b/equinox-fc/Domain/Location.fs @@ -0,0 +1,45 @@ +namespace Location + +[] +type Wip<'R> = + | Pending of decide : (Epoch.Folds.Balance -> 'R*Epoch.Events.Event list) + | Complete of 'R + +/// Manages a Series of Epochs, with a running total being carried forward to the next Epoch when it's Closed +type LocationService internal (zeroBalance, shouldClose, series : Series.Service, epochs : Epoch.Service) = + + let rec execute locationId originEpochId = + let rec aux epochId balanceToCarryForward wip = async { + let decide state = match wip with Complete r -> r,[] | Pending decide -> decide state + match! epochs.Sync(locationId, epochId, balanceToCarryForward, decide, shouldClose) with + | { balance = bal; result = Some res; isOpen = true } -> + if originEpochId <> epochId then + do! series.ActivateEpoch(locationId, epochId) + return bal, res + | { balance = bal; result = Some res } -> + let successorEpochId = LocationEpochId.next epochId + return! aux successorEpochId (Some bal) (Complete res) + | { balance = bal } -> + let successorEpochId = LocationEpochId.next epochId + return! aux successorEpochId (Some bal) wip } + aux + + member __.Execute(locationId, decide) = async { + let! activeEpoch = series.Read locationId + let originEpochId, epochId, balanceCarriedForward = + match activeEpoch with + | None -> LocationEpochId.parse -1, LocationEpochId.parse 0, Some zeroBalance + | Some activeEpochId -> activeEpochId, activeEpochId, None + return! execute locationId originEpochId epochId balanceCarriedForward (Pending decide)} + +[] +module Helpers = + let create (zeroBalance, shouldClose) (series, epochs) = + LocationService(zeroBalance, shouldClose, series, epochs) + +module Cosmos = + + let createService (zeroBalance, shouldClose) (context, cache, maxAttempts) = + let series = Series.Cosmos.createService (context, cache, maxAttempts) + let epochs = Epoch.Cosmos.createService (context, cache, maxAttempts) + create (zeroBalance, shouldClose) (series, epochs) \ No newline at end of file diff --git a/equinox-fc/Domain/LocationEpoch.fs b/equinox-fc/Domain/LocationEpoch.fs new file mode 100644 index 000000000..a76b7807d --- /dev/null +++ b/equinox-fc/Domain/LocationEpoch.fs @@ -0,0 +1,90 @@ +module Location.Epoch + +// NOTE - these types and the union case names reflect the actual storage formats and hence need to be versioned with care +[] +module Events = + + type CarriedForward = { initial : int } + type Delta = { value : int } + type Event = + | CarriedForward of CarriedForward + | Closed + | Delta of Delta + interface TypeShape.UnionContract.IUnionContract + let codec = FsCodec.NewtonsoftJson.Codec.Create() + let [] categoryId = "LocationEpoch" + +module Folds = + + type Balance = int + type OpenState = { count : int; value : Balance } + type State = Initial | Open of OpenState | Closed of Balance + let initial = Initial + let evolve state event = + match event, state with + | Events.CarriedForward e, Initial -> Open { count = 0; value = e.initial } + | Events.Delta e, Open bal -> Open { count = bal.count + 1; value = bal.value + e.value } + | Events.Closed, Open { value = bal } -> Closed bal + | Events.CarriedForward _, (Open _|Closed _ as x) -> failwithf "CarriedForward : Unexpected %A" x + | Events.Delta _, (Initial|Closed _ as x) -> failwithf "Delta : Unexpected %A" x + | Events.Closed, (Initial|Closed _ as x) -> failwithf "Closed : Unexpected %A" x + let fold = Seq.fold evolve + +/// Holds events accumulated from a series of decisions while also evolving the presented `state` to reflect the pended events +type private Accumulator() = + let acc = ResizeArray() + member __.Ingest state : 'res * Events.Event list -> 'res * Folds.State = function + | res, [] -> res, state + | res, [e] -> acc.Add e; res, Folds.evolve state e + | res, xs -> acc.AddRange xs; res, Folds.fold state (Seq.ofList xs) + member __.Accumulated = List.ofSeq acc + +type Result<'t> = { balance : Folds.Balance; result : 't option; isOpen : bool } + +let sync (balanceCarriedForward : Folds.Balance option) (decide : (Folds.Balance -> 't*Events.Event list)) shouldClose state : Result<'t>*Events.Event list = + let acc = Accumulator() + // We always want to have a CarriedForward event at the start of any Epoch's event stream + let (), state = + acc.Ingest state <| + match state with + | Folds.Initial -> (), [Events.CarriedForward { initial = Option.get balanceCarriedForward }] + | Folds.Open _ | Folds.Closed _ -> (), [] + // Run, unless we determine we're in Closed state + let result, state = + acc.Ingest state <| + match state with + | Folds.Initial -> failwith "We've just guaranteed not Initial" + | Folds.Open { value = bal } -> let r,es = decide bal in Some r,es + | Folds.Closed _ -> None, [] + // Finally (iff we're `Open`, have run a `decide` and `shouldClose`), we generate a Closed event + let (balance, isOpen), _ = + acc.Ingest state <| + match state with + | Folds.Initial -> failwith "Can't be Initial" + | Folds.Open ({ value = bal } as openState) when shouldClose openState -> (bal, false), [Events.Closed] + | Folds.Open { value = bal } -> (bal, true), [] + | Folds.Closed bal -> (bal, false), [] + { balance = balance; result = result; isOpen = isOpen }, acc.Accumulated + +type Service internal (resolve, ?maxAttempts) = + + let log = Serilog.Log.ForContext() + let (|AggregateId|) (locationId, epochId) = + let id = sprintf "%s_%s" (LocationId.toString locationId) (LocationEpochId.toString epochId) + Equinox.AggregateId(Events.categoryId, id) + let (|Stream|) (AggregateId id) = Equinox.Stream(log, resolve id, maxAttempts = defaultArg maxAttempts 2) + + member __.Sync<'R>(locationId, epochId, prevEpochBalanceCarriedForward, decide, shouldClose) : Async> = + let (Stream stream) = (locationId, epochId) + stream.Transact(sync prevEpochBalanceCarriedForward decide shouldClose) + +let create resolve maxAttempts = Service(resolve, maxAttempts) + +module Cosmos = + + open Equinox.Cosmos + let resolve (context,cache) = + let cacheStrategy = CachingStrategy.SlidingWindow (cache, System.TimeSpan.FromMinutes 20.) + Resolver(context, Events.codec, Folds.fold, Folds.initial, cacheStrategy, AccessStrategy.LatestKnownEvent).Resolve + let createService (context,cache,maxAttempts) = + create (resolve (context,cache)) maxAttempts \ No newline at end of file diff --git a/equinox-fc/Domain/LocationSeries.fs b/equinox-fc/Domain/LocationSeries.fs new file mode 100644 index 000000000..e75ccb9b4 --- /dev/null +++ b/equinox-fc/Domain/LocationSeries.fs @@ -0,0 +1,52 @@ +module Location.Series + +// NOTE - these types and the union case names reflect the actual storage formats and hence need to be versioned with care +[] +module Events = + + type Started = { epochId : LocationEpochId } + type Event = + | Started of Started + interface TypeShape.UnionContract.IUnionContract + let codec = FsCodec.NewtonsoftJson.Codec.Create() + let [] categoryId = "LocationSeries" + +module Folds = + + type State = LocationEpochId + let initial = LocationEpochId.parse -1 + let evolve _state = function + | Events.Started e -> e.epochId + let fold = Seq.fold evolve + +let interpretActivateEpoch epochId (state : Folds.State) = + [if state < epochId then yield Events.Started { epochId = epochId }] + +let toActiveEpoch state = + if state = Folds.initial then None else Some state + +type Service internal (resolve, ?maxAttempts) = + + let log = Serilog.Log.ForContext() + let (|AggregateId|) id = Equinox.AggregateId(Events.categoryId, LocationId.toString id) + let (|Stream|) (AggregateId id) = Equinox.Stream(log, resolve id, maxAttempts = defaultArg maxAttempts 2) + + member __.Read(locationId) : Async = + let (Stream stream) = locationId + stream.Query toActiveEpoch + + member __.ActivateEpoch(locationId,epochId) : Async = + let (Stream stream) = locationId + stream.Transact(interpretActivateEpoch epochId) + +let create resolve maxAttempts = Service(resolve, maxAttempts) + +module Cosmos = + + open Equinox.Cosmos + let resolve (context,cache) = + let cacheStrategy = CachingStrategy.SlidingWindow (cache, System.TimeSpan.FromMinutes 20.) + let opt = Equinox.ResolveOption.AllowStale + fun id -> Resolver(context, Events.codec, Folds.fold, Folds.initial, cacheStrategy, AccessStrategy.LatestKnownEvent).Resolve(id,opt) + let createService (context, cache, maxAttempts) = + create (resolve (context,cache)) maxAttempts \ No newline at end of file diff --git a/equinox-fc/Fc.sln b/equinox-fc/Fc.sln new file mode 100644 index 000000000..2bf47b4d9 --- /dev/null +++ b/equinox-fc/Fc.sln @@ -0,0 +1,48 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio 15 +VisualStudioVersion = 15.0.26124.0 +MinimumVisualStudioVersion = 15.0.26124.0 +Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Domain", "Domain\Domain.fsproj", "{4722DF4D-DBE1-4030-9C55-6BA3A4147322}" +EndProject +Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Domain.Tests", "Domain.Tests\Domain.Tests.fsproj", "{AA589DC7-D0B7-4F95-BC29-6B89AF4E9280}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {4722DF4D-DBE1-4030-9C55-6BA3A4147322}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4722DF4D-DBE1-4030-9C55-6BA3A4147322}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4722DF4D-DBE1-4030-9C55-6BA3A4147322}.Debug|x64.ActiveCfg = Debug|Any CPU + {4722DF4D-DBE1-4030-9C55-6BA3A4147322}.Debug|x64.Build.0 = Debug|Any CPU + {4722DF4D-DBE1-4030-9C55-6BA3A4147322}.Debug|x86.ActiveCfg = Debug|Any CPU + {4722DF4D-DBE1-4030-9C55-6BA3A4147322}.Debug|x86.Build.0 = Debug|Any CPU + {4722DF4D-DBE1-4030-9C55-6BA3A4147322}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4722DF4D-DBE1-4030-9C55-6BA3A4147322}.Release|Any CPU.Build.0 = Release|Any CPU + {4722DF4D-DBE1-4030-9C55-6BA3A4147322}.Release|x64.ActiveCfg = Release|Any CPU + {4722DF4D-DBE1-4030-9C55-6BA3A4147322}.Release|x64.Build.0 = Release|Any CPU + {4722DF4D-DBE1-4030-9C55-6BA3A4147322}.Release|x86.ActiveCfg = Release|Any CPU + {4722DF4D-DBE1-4030-9C55-6BA3A4147322}.Release|x86.Build.0 = Release|Any CPU + {AA589DC7-D0B7-4F95-BC29-6B89AF4E9280}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AA589DC7-D0B7-4F95-BC29-6B89AF4E9280}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AA589DC7-D0B7-4F95-BC29-6B89AF4E9280}.Debug|x64.ActiveCfg = Debug|Any CPU + {AA589DC7-D0B7-4F95-BC29-6B89AF4E9280}.Debug|x64.Build.0 = Debug|Any CPU + {AA589DC7-D0B7-4F95-BC29-6B89AF4E9280}.Debug|x86.ActiveCfg = Debug|Any CPU + {AA589DC7-D0B7-4F95-BC29-6B89AF4E9280}.Debug|x86.Build.0 = Debug|Any CPU + {AA589DC7-D0B7-4F95-BC29-6B89AF4E9280}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AA589DC7-D0B7-4F95-BC29-6B89AF4E9280}.Release|Any CPU.Build.0 = Release|Any CPU + {AA589DC7-D0B7-4F95-BC29-6B89AF4E9280}.Release|x64.ActiveCfg = Release|Any CPU + {AA589DC7-D0B7-4F95-BC29-6B89AF4E9280}.Release|x64.Build.0 = Release|Any CPU + {AA589DC7-D0B7-4F95-BC29-6B89AF4E9280}.Release|x86.ActiveCfg = Release|Any CPU + {AA589DC7-D0B7-4F95-BC29-6B89AF4E9280}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal From f20368045e140be05db4cbfc2d0e0ba6b0d46cbc Mon Sep 17 00:00:00 2001 From: Ruben Bartelink Date: Fri, 29 Nov 2019 16:58:58 +0000 Subject: [PATCH 02/51] Add Fc template wrapping --- CHANGELOG.md | 1 + equinox-fc/.template.config/template.json | 17 +++++++++++++++++ 2 files changed, 18 insertions(+) create mode 100644 equinox-fc/.template.config/template.json diff --git a/CHANGELOG.md b/CHANGELOG.md index edc893cb9..5ad7af7d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -191,6 +191,7 @@ The `Unreleased` section name is replaced by the expected version of next releas ### Added - Add handling for `-T` TCP/IP switch on EventStore args [#46](https://github.com/jet/dotnet-templates/pulls/46) +- `equinox-fc`: Fulfilment-Center inspired example utilizing Process Manager patterns with basic `Equinox.MemoryStore` and `Equinox.Cosmos` tests [#40](https://github.com/jet/dotnet-templates/pulls/40) ### Changed diff --git a/equinox-fc/.template.config/template.json b/equinox-fc/.template.config/template.json new file mode 100644 index 000000000..20972bd61 --- /dev/null +++ b/equinox-fc/.template.config/template.json @@ -0,0 +1,17 @@ +{ + "$schema": "http://json.schemastore.org/template", + "author": "@jet @bartelink", + "classifications": [ + "Equinox", + "Event Sourcing", + "Fc" + ], + "tags": { + "language": "F#" + }, + "identity": "Equinox.Fc", + "name": "Equinox Fc Example", + "shortName": "eqxfc", + "sourceName": "Fc", + "preferNameDirectory": true +} \ No newline at end of file From 7a3690e6260f343895593163dd094166fec7e6ae Mon Sep 17 00:00:00 2001 From: Ruben Bartelink Date: Fri, 29 Nov 2019 20:39:51 +0000 Subject: [PATCH 03/51] Correct AccessStrategy --- equinox-fc/Domain/LocationEpoch.fs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/equinox-fc/Domain/LocationEpoch.fs b/equinox-fc/Domain/LocationEpoch.fs index a76b7807d..37c46b6d5 100644 --- a/equinox-fc/Domain/LocationEpoch.fs +++ b/equinox-fc/Domain/LocationEpoch.fs @@ -85,6 +85,6 @@ module Cosmos = open Equinox.Cosmos let resolve (context,cache) = let cacheStrategy = CachingStrategy.SlidingWindow (cache, System.TimeSpan.FromMinutes 20.) - Resolver(context, Events.codec, Folds.fold, Folds.initial, cacheStrategy, AccessStrategy.LatestKnownEvent).Resolve + Resolver(context, Events.codec, Folds.fold, Folds.initial, cacheStrategy, AccessStrategy.Unoptimized).Resolve let createService (context,cache,maxAttempts) = create (resolve (context,cache)) maxAttempts \ No newline at end of file From 2a7f6ba2901e56ae7a18da9ca8a87851c549ab34 Mon Sep 17 00:00:00 2001 From: Ruben Bartelink Date: Fri, 29 Nov 2019 20:40:11 +0000 Subject: [PATCH 04/51] Fix streamId ordering --- equinox-fc/Domain.Tests/LocationTests.fs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/equinox-fc/Domain.Tests/LocationTests.fs b/equinox-fc/Domain.Tests/LocationTests.fs index 2e117f65f..f3835b08f 100644 --- a/equinox-fc/Domain.Tests/LocationTests.fs +++ b/equinox-fc/Domain.Tests/LocationTests.fs @@ -29,7 +29,7 @@ module Location = let run (service : LocationService) (IdsAtLeastOne locations, deltas : _[]) = Async.RunSynchronously <| async { let runId = mkId () // Need to make making state in store unique when replaying or shrinking - let locations = locations |> Array.map (fun x -> % (sprintf "%O_%O" runId x)) + let locations = locations |> Array.map (fun x -> % (sprintf "%O_%O" x runId)) let updates = deltas |> Seq.mapi (fun i x -> locations.[i % locations.Length], x) |> Seq.cache From 5828bb8c432c549cb437253bbcf56574d371193a Mon Sep 17 00:00:00 2001 From: Ruben Bartelink Date: Fri, 29 Nov 2019 20:40:35 +0000 Subject: [PATCH 05/51] Tidy log output --- equinox-fc/Domain.Tests/Infrastructure.fs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/equinox-fc/Domain.Tests/Infrastructure.fs b/equinox-fc/Domain.Tests/Infrastructure.fs index 80c23b5eb..eddefa50f 100644 --- a/equinox-fc/Domain.Tests/Infrastructure.fs +++ b/equinox-fc/Domain.Tests/Infrastructure.fs @@ -32,7 +32,7 @@ module Cosmos = /// Adapts the XUnit ITestOutputHelper to be a Serilog Sink type TestOutputAdapter(testOutput : Xunit.Abstractions.ITestOutputHelper) = - let formatter = Serilog.Formatting.Display.MessageTemplateTextFormatter("{Timestamp:HH:mm:ss.fff zzz} [{Level:u3}] {Message}{Properties}{NewLine}{Exception}", null); + let formatter = Serilog.Formatting.Display.MessageTemplateTextFormatter("{Timestamp:HH:mm:ss.fff zzz} [{Level:u3}] {Message} {Properties}{NewLine}{Exception}", null); let writeSerilogEvent logEvent = use writer = new System.IO.StringWriter() formatter.Format(logEvent, writer) From 6aceccc26de25ff8c611c57f645474c0947388e7 Mon Sep 17 00:00:00 2001 From: Ruben Bartelink Date: Fri, 29 Nov 2019 21:21:37 +0000 Subject: [PATCH 06/51] Tidy connection logic --- equinox-fc/Domain.Tests/Infrastructure.fs | 10 +++++----- equinox-fc/Domain.Tests/LocationTests.fs | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/equinox-fc/Domain.Tests/Infrastructure.fs b/equinox-fc/Domain.Tests/Infrastructure.fs index eddefa50f..66dd13d73 100644 --- a/equinox-fc/Domain.Tests/Infrastructure.fs +++ b/equinox-fc/Domain.Tests/Infrastructure.fs @@ -18,14 +18,14 @@ module Cosmos = open Equinox.Cosmos let connect () = match EnvVar.tryGet "EQUINOX_COSMOS_CONNECTION", EnvVar.tryGet "EQUINOX_COSMOS_DATABASE", EnvVar.tryGet "EQUINOX_COSMOS_CONTAINER" with - | Some s,Some d,Some c -> + | Some s, Some d, Some c -> let appName = "Domain.Tests" let discovery = Discovery.FromConnectionString s - let connector = Connector(TimeSpan.FromSeconds 5., 1, TimeSpan.FromSeconds 5., Serilog.Log.Logger) - let connection = connector.Connect(appName,discovery) |> Async.RunSynchronously - let context = Context(connection,d,c) + let connector = Connector(TimeSpan.FromSeconds 5., 5, TimeSpan.FromSeconds 5., Serilog.Log.Logger) + let connection = connector.Connect(appName, discovery) |> Async.RunSynchronously + let context = Context(connection, d, c) let cache = Equinox.Cache (appName, 10) - context,cache + context, cache | s,d,c -> failwithf "Connection, Database and Container EQUINOX_COSMOS_* Environment variables are required (%b,%b,%b)" (Option.isSome s) (Option.isSome d) (Option.isSome c) diff --git a/equinox-fc/Domain.Tests/LocationTests.fs b/equinox-fc/Domain.Tests/LocationTests.fs index f3835b08f..b4bacbaad 100644 --- a/equinox-fc/Domain.Tests/LocationTests.fs +++ b/equinox-fc/Domain.Tests/LocationTests.fs @@ -62,7 +62,7 @@ type Cosmos(testOutput) = let log = testOutput |> TestOutputAdapter |> createLogger do Serilog.Log.Logger <- log - let [] properties maxEvents args = + let [] properties maxEvents args = let zeroBalance = 0 let maxEvents = max 1 maxEvents let shouldClose (state : Epoch.Folds.OpenState) = state.count > maxEvents From 5bd3ccf1974b9e8c1f47f5087a2ac2014ee6ab7c Mon Sep 17 00:00:00 2001 From: Ruben Bartelink Date: Fri, 29 Nov 2019 22:49:40 +0000 Subject: [PATCH 07/51] Add README --- README.md | 1 + equinox-fc/Domain/Location.fs | 1 + equinox-fc/Fc.sln | 5 +++++ equinox-fc/README.md | 26 ++++++++++++++++++++++++++ 4 files changed, 33 insertions(+) create mode 100644 equinox-fc/README.md diff --git a/README.md b/README.md index 6996b39b4..2bf8ca4ea 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ These templates focus solely on Consistent Processing using Equinox Stores: - [`eqxweb`](equinox-web/README.md) - Boilerplate for an ASP .NET Core 2 Web App, with an associated storage-independent Domain project using [Equinox](https://github.com/jet/equinox). - [`eqxwebcs`](equinox-web-csharp/README.md) - Boilerplate for an ASP .NET Core 2 Web App, with an associated storage-independent Domain project using [Equinox](https://github.com/jet/equinox), _ported to C#_. - [`eqxtestbed`](equinox-testbed/README.md) - Host that allows running back-to-back benchmarks when prototyping models using [Equinox](https://github.com/jet/equinox), using different stores and/or store configuration parameters. +- [`eqxfc`](equinox-fc/README.md) - Samples showcasing various modeling and testing techniques such as (FsCheck-based) unit tests and use of `MemoryStore` for integration tests. ## [Propulsion](https://github.com/jet/propulsion) related diff --git a/equinox-fc/Domain/Location.fs b/equinox-fc/Domain/Location.fs index 50511d8d3..ff6b6b133 100644 --- a/equinox-fc/Domain/Location.fs +++ b/equinox-fc/Domain/Location.fs @@ -34,6 +34,7 @@ type LocationService internal (zeroBalance, shouldClose, series : Series.Service [] module Helpers = + let create (zeroBalance, shouldClose) (series, epochs) = LocationService(zeroBalance, shouldClose, series, epochs) diff --git a/equinox-fc/Fc.sln b/equinox-fc/Fc.sln index 2bf47b4d9..bfb82d823 100644 --- a/equinox-fc/Fc.sln +++ b/equinox-fc/Fc.sln @@ -7,6 +7,11 @@ Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Domain", "Domain\Domain.fsp EndProject Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Domain.Tests", "Domain.Tests\Domain.Tests.fsproj", "{AA589DC7-D0B7-4F95-BC29-6B89AF4E9280}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".project", ".project", "{FEADBAC9-0CA2-440B-AF9B-184FED9B362E}" +ProjectSection(SolutionItems) = preProject + README.md = README.md +EndProjectSection +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU diff --git a/equinox-fc/README.md b/equinox-fc/README.md new file mode 100644 index 000000000..39e27fd7c --- /dev/null +++ b/equinox-fc/README.md @@ -0,0 +1,26 @@ +# Equinox FC Sample + +This project was generated using: + + dotnet new -i Equinox.Templates # just once, to install in the local templates store + + dotnet new eqxfc # use --help to see options regarding storage subsystem configuration etc + +# `Location*` + +The `Location`* `module`s illustrates a way to approach the modelling of a long-running state by having writers adhere to a common protocol when writing: + +- the `LocationEpoch` category represents a span of time, which is guaranteed to have a `CarriedForward` event representing the opening balance, and/or the balance carried forward when the preceding epoch was marked `Closed` +- the `LocationSeries` category bears the verified active epoch (competing readers/writers read this optimistically on a cached basis; in the event that they're behind, they'll compete to log the successful commencement of a new epoch, which cannot happen before the `CarriedForward` event for the successor epoch has been committed) + +- `Domain.Tests` includes (`FsCheck`) Property-based unit tests, and an integration tests that demonstrates parallel writes (that trigger Optimistic Concurrency Control-based conflict resolution, including against `MemoryStore`) + +## Notes + +- Referencing an `Equinox.*` Store module from the `Domain` project is not mandatory; it's common to defer all wiring and configuration of the elements in `module Cosmos`, `module EventStore` etc. and instead maintain that alongside the Composition Root, outside of the `Domain` project + +- While using an `AccessStrategy` such as `Snapshotting` may in some cases be relevant too, in the general case, using the `Equinox.Cache`, combined with having a compact Fold `State` and a sufficiently constrained maximum number/size of events means the state can be established within a predictable latency range. + +- Representing a long-running state in this fashion is no panacea; in modeling a system, the ideal is to have streams that have a naturally constrained number of events over their lifetime. + +- [Original PR](https://github.com/jet/dotnet-templates/pull/40) \ No newline at end of file From 2600c20c0edc3a06418a8143d9d015818478d591 Mon Sep 17 00:00:00 2001 From: Ruben Bartelink Date: Sat, 7 Dec 2019 03:24:54 +0000 Subject: [PATCH 08/51] Move AggregateId to Events --- equinox-fc/Domain/LocationEpoch.fs | 8 ++++---- equinox-fc/Domain/LocationSeries.fs | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/equinox-fc/Domain/LocationEpoch.fs b/equinox-fc/Domain/LocationEpoch.fs index 37c46b6d5..83725eb66 100644 --- a/equinox-fc/Domain/LocationEpoch.fs +++ b/equinox-fc/Domain/LocationEpoch.fs @@ -13,6 +13,9 @@ module Events = interface TypeShape.UnionContract.IUnionContract let codec = FsCodec.NewtonsoftJson.Codec.Create() let [] categoryId = "LocationEpoch" + let (|AggregateId|) (locationId, epochId) = + let id = sprintf "%s_%s" (LocationId.toString locationId) (LocationEpochId.toString epochId) + Equinox.AggregateId(categoryId, id) module Folds = @@ -69,10 +72,7 @@ let sync (balanceCarriedForward : Folds.Balance option) (decide : (Folds.Balance type Service internal (resolve, ?maxAttempts) = let log = Serilog.Log.ForContext() - let (|AggregateId|) (locationId, epochId) = - let id = sprintf "%s_%s" (LocationId.toString locationId) (LocationEpochId.toString epochId) - Equinox.AggregateId(Events.categoryId, id) - let (|Stream|) (AggregateId id) = Equinox.Stream(log, resolve id, maxAttempts = defaultArg maxAttempts 2) + let (|Stream|) (Events.AggregateId id) = Equinox.Stream(log, resolve id, maxAttempts = defaultArg maxAttempts 2) member __.Sync<'R>(locationId, epochId, prevEpochBalanceCarriedForward, decide, shouldClose) : Async> = let (Stream stream) = (locationId, epochId) diff --git a/equinox-fc/Domain/LocationSeries.fs b/equinox-fc/Domain/LocationSeries.fs index e75ccb9b4..8beebe800 100644 --- a/equinox-fc/Domain/LocationSeries.fs +++ b/equinox-fc/Domain/LocationSeries.fs @@ -10,6 +10,7 @@ module Events = interface TypeShape.UnionContract.IUnionContract let codec = FsCodec.NewtonsoftJson.Codec.Create() let [] categoryId = "LocationSeries" + let (|AggregateId|) id = Equinox.AggregateId(categoryId, LocationId.toString id) module Folds = @@ -28,8 +29,7 @@ let toActiveEpoch state = type Service internal (resolve, ?maxAttempts) = let log = Serilog.Log.ForContext() - let (|AggregateId|) id = Equinox.AggregateId(Events.categoryId, LocationId.toString id) - let (|Stream|) (AggregateId id) = Equinox.Stream(log, resolve id, maxAttempts = defaultArg maxAttempts 2) + let (|Stream|) (Events.AggregateId id) = Equinox.Stream(log, resolve id, maxAttempts = defaultArg maxAttempts 2) member __.Read(locationId) : Async = let (Stream stream) = locationId From 7c7db7d81fd27853826d9f70c96ad5b202b80020 Mon Sep 17 00:00:00 2001 From: Ruben Bartelink Date: Sat, 7 Dec 2019 09:18:10 +0000 Subject: [PATCH 09/51] Aggregate layout/naming consistency --- equinox-fc/Domain.Tests/LocationEpochTests.fs | 4 +- .../Domain.Tests/LocationSeriesTests.fs | 6 +-- equinox-fc/Domain.Tests/LocationTests.fs | 18 +++---- equinox-fc/Domain/Location.fs | 2 +- equinox-fc/Domain/LocationEpoch.fs | 47 +++++++++---------- equinox-fc/Domain/LocationSeries.fs | 25 +++++----- 6 files changed, 50 insertions(+), 52 deletions(-) diff --git a/equinox-fc/Domain.Tests/LocationEpochTests.fs b/equinox-fc/Domain.Tests/LocationEpochTests.fs index 8a4e14a4a..6901aa746 100644 --- a/equinox-fc/Domain.Tests/LocationEpochTests.fs +++ b/equinox-fc/Domain.Tests/LocationEpochTests.fs @@ -22,10 +22,10 @@ let [] properties carriedForward delta1 closeImmediately delta2 close (* Starting with an empty stream, we'll need to supply the balance carried forward, optionally we apply a delta and potentially close *) let initialShouldClose _state = closeImmediately - let res,events = sync (Some carriedForward) (validateAndInterpret carriedForward delta1) initialShouldClose Folds.initial + let res,events = sync (Some carriedForward) (validateAndInterpret carriedForward delta1) initialShouldClose Fold.initial let cfEvents events = events |> List.filter (function Events.CarriedForward _ -> true | _ -> false) let closeEvents events = events |> List.filter (function Events.Closed -> true | _ -> false) - let state1 = Folds.fold Folds.initial events + let state1 = Fold.fold Fold.initial events let expectedBalance = carriedForward + delta1 // Only expect closing if it was requested let expectImmediateClose = closeImmediately diff --git a/equinox-fc/Domain.Tests/LocationSeriesTests.fs b/equinox-fc/Domain.Tests/LocationSeriesTests.fs index 1bd09a28d..b0ec09dd7 100644 --- a/equinox-fc/Domain.Tests/LocationSeriesTests.fs +++ b/equinox-fc/Domain.Tests/LocationSeriesTests.fs @@ -6,8 +6,8 @@ open Swensen.Unquote open Location.Series let [] properties c1 c2 = - let events = interpretActivateEpoch c1 Folds.initial - let state1 = Folds.fold Folds.initial events + let events = interpretActivateEpoch c1 Fold.initial + let state1 = Fold.fold Fold.initial events let epoch0 = %0 match c1, events, toActiveEpoch state1 with // Started events are not written for < 0 @@ -21,7 +21,7 @@ let [] properties c1 c2 = test <@ List.isEmpty l @> let events = interpretActivateEpoch c2 state1 - let state2 = Folds.fold state1 events + let state2 = Fold.fold state1 events match toActiveEpoch state1, c2, events, toActiveEpoch state2 with // Started events are not written for < 0 | None, n, [], activeEpoch when n < epoch0 -> diff --git a/equinox-fc/Domain.Tests/LocationTests.fs b/equinox-fc/Domain.Tests/LocationTests.fs index b4bacbaad..169834991 100644 --- a/equinox-fc/Domain.Tests/LocationTests.fs +++ b/equinox-fc/Domain.Tests/LocationTests.fs @@ -9,17 +9,17 @@ open System /// Helpers to match `module Cosmos` wrapping inside the impl module Location = - open Equinox.MemoryStore + module MemoryStore = - module Series = + open Equinox.MemoryStore - let resolve store = Resolver(store, Series.Events.codec, Series.Folds.fold, Series.Folds.initial).Resolve + module Series = - module Epoch = + let resolve store = Resolver(store, Series.Events.codec, Series.Fold.fold, Series.Fold.initial).Resolve - let resolve store = Resolver(store, Epoch.Events.codec, Epoch.Folds.fold, Epoch.Folds.initial).Resolve + module Epoch = - module MemoryStore = + let resolve store = Resolver(store, Epoch.Events.codec, Epoch.Fold.fold, Epoch.Fold.initial).Resolve let createService (zeroBalance, shouldClose) store = let maxAttempts = Int32.MaxValue @@ -35,7 +35,7 @@ let run (service : LocationService) (IdsAtLeastOne locations, deltas : _[]) = As (* Apply random deltas *) - let adjust delta (bal : Epoch.Folds.Balance) = + let adjust delta (bal : Epoch.Fold.Balance) = let value = max -bal delta if value = 0 then 0, [] else value, [Location.Epoch.Events.Delta { value = value }] @@ -51,7 +51,7 @@ let [] ``MemoryStore properties`` maxEvents args = let store = Equinox.MemoryStore.VolatileStore() let zeroBalance = 0 let maxEvents = max 1 maxEvents - let shouldClose (state : Epoch.Folds.OpenState) = state.count > maxEvents + let shouldClose (state : Epoch.Fold.OpenState) = state.count > maxEvents let service = Location.MemoryStore.createService (zeroBalance, shouldClose) store run service args @@ -65,6 +65,6 @@ type Cosmos(testOutput) = let [] properties maxEvents args = let zeroBalance = 0 let maxEvents = max 1 maxEvents - let shouldClose (state : Epoch.Folds.OpenState) = state.count > maxEvents + let shouldClose (state : Epoch.Fold.OpenState) = state.count > maxEvents let service = Location.Cosmos.createService (zeroBalance, shouldClose) (context,cache,Int32.MaxValue) run service args \ No newline at end of file diff --git a/equinox-fc/Domain/Location.fs b/equinox-fc/Domain/Location.fs index ff6b6b133..7e4468f58 100644 --- a/equinox-fc/Domain/Location.fs +++ b/equinox-fc/Domain/Location.fs @@ -2,7 +2,7 @@ namespace Location [] type Wip<'R> = - | Pending of decide : (Epoch.Folds.Balance -> 'R*Epoch.Events.Event list) + | Pending of decide : (Epoch.Fold.Balance -> 'R*Epoch.Events.Event list) | Complete of 'R /// Manages a Series of Epochs, with a running total being carried forward to the next Epoch when it's Closed diff --git a/equinox-fc/Domain/LocationEpoch.fs b/equinox-fc/Domain/LocationEpoch.fs index 83725eb66..e2f41d27f 100644 --- a/equinox-fc/Domain/LocationEpoch.fs +++ b/equinox-fc/Domain/LocationEpoch.fs @@ -12,12 +12,12 @@ module Events = | Delta of Delta interface TypeShape.UnionContract.IUnionContract let codec = FsCodec.NewtonsoftJson.Codec.Create() - let [] categoryId = "LocationEpoch" - let (|AggregateId|) (locationId, epochId) = + let [] category = "LocationEpoch" + let (|For|) (locationId, epochId) = let id = sprintf "%s_%s" (LocationId.toString locationId) (LocationEpochId.toString epochId) - Equinox.AggregateId(categoryId, id) + Equinox.AggregateId(category, id) -module Folds = +module Fold = type Balance = int type OpenState = { count : int; value : Balance } @@ -36,55 +36,54 @@ module Folds = /// Holds events accumulated from a series of decisions while also evolving the presented `state` to reflect the pended events type private Accumulator() = let acc = ResizeArray() - member __.Ingest state : 'res * Events.Event list -> 'res * Folds.State = function + member __.Ingest state : 'res * Events.Event list -> 'res * Fold.State = function | res, [] -> res, state - | res, [e] -> acc.Add e; res, Folds.evolve state e - | res, xs -> acc.AddRange xs; res, Folds.fold state (Seq.ofList xs) + | res, [e] -> acc.Add e; res, Fold.evolve state e + | res, xs -> acc.AddRange xs; res, Fold.fold state (Seq.ofList xs) member __.Accumulated = List.ofSeq acc -type Result<'t> = { balance : Folds.Balance; result : 't option; isOpen : bool } +type Result<'t> = { balance : Fold.Balance; result : 't option; isOpen : bool } -let sync (balanceCarriedForward : Folds.Balance option) (decide : (Folds.Balance -> 't*Events.Event list)) shouldClose state : Result<'t>*Events.Event list = +let sync (balanceCarriedForward : Fold.Balance option) (decide : (Fold.Balance -> 't*Events.Event list)) shouldClose state : Result<'t>*Events.Event list = let acc = Accumulator() // We always want to have a CarriedForward event at the start of any Epoch's event stream let (), state = acc.Ingest state <| match state with - | Folds.Initial -> (), [Events.CarriedForward { initial = Option.get balanceCarriedForward }] - | Folds.Open _ | Folds.Closed _ -> (), [] + | Fold.Initial -> (), [Events.CarriedForward { initial = Option.get balanceCarriedForward }] + | Fold.Open _ | Fold.Closed _ -> (), [] // Run, unless we determine we're in Closed state let result, state = acc.Ingest state <| match state with - | Folds.Initial -> failwith "We've just guaranteed not Initial" - | Folds.Open { value = bal } -> let r,es = decide bal in Some r,es - | Folds.Closed _ -> None, [] + | Fold.Initial -> failwith "We've just guaranteed not Initial" + | Fold.Open { value = bal } -> let r,es = decide bal in Some r,es + | Fold.Closed _ -> None, [] // Finally (iff we're `Open`, have run a `decide` and `shouldClose`), we generate a Closed event let (balance, isOpen), _ = acc.Ingest state <| match state with - | Folds.Initial -> failwith "Can't be Initial" - | Folds.Open ({ value = bal } as openState) when shouldClose openState -> (bal, false), [Events.Closed] - | Folds.Open { value = bal } -> (bal, true), [] - | Folds.Closed bal -> (bal, false), [] + | Fold.Initial -> failwith "Can't be Initial" + | Fold.Open ({ value = bal } as openState) when shouldClose openState -> (bal, false), [Events.Closed] + | Fold.Open { value = bal } -> (bal, true), [] + | Fold.Closed bal -> (bal, false), [] { balance = balance; result = result; isOpen = isOpen }, acc.Accumulated -type Service internal (resolve, ?maxAttempts) = +type Service internal (log, resolve, maxAttempts) = - let log = Serilog.Log.ForContext() - let (|Stream|) (Events.AggregateId id) = Equinox.Stream(log, resolve id, maxAttempts = defaultArg maxAttempts 2) + let resolve (Events.For id) = Equinox.Stream(log, resolve id, maxAttempts) member __.Sync<'R>(locationId, epochId, prevEpochBalanceCarriedForward, decide, shouldClose) : Async> = - let (Stream stream) = (locationId, epochId) + let stream = resolve (locationId, epochId) stream.Transact(sync prevEpochBalanceCarriedForward decide shouldClose) -let create resolve maxAttempts = Service(resolve, maxAttempts) +let create resolve maxAttempts = Service(Serilog.Log.ForContext(), resolve, maxAttempts = maxAttempts) module Cosmos = open Equinox.Cosmos let resolve (context,cache) = let cacheStrategy = CachingStrategy.SlidingWindow (cache, System.TimeSpan.FromMinutes 20.) - Resolver(context, Events.codec, Folds.fold, Folds.initial, cacheStrategy, AccessStrategy.Unoptimized).Resolve + Resolver(context, Events.codec, Fold.fold, Fold.initial, cacheStrategy, AccessStrategy.Unoptimized).Resolve let createService (context,cache,maxAttempts) = create (resolve (context,cache)) maxAttempts \ No newline at end of file diff --git a/equinox-fc/Domain/LocationSeries.fs b/equinox-fc/Domain/LocationSeries.fs index 8beebe800..7c7d1e6c8 100644 --- a/equinox-fc/Domain/LocationSeries.fs +++ b/equinox-fc/Domain/LocationSeries.fs @@ -9,10 +9,10 @@ module Events = | Started of Started interface TypeShape.UnionContract.IUnionContract let codec = FsCodec.NewtonsoftJson.Codec.Create() - let [] categoryId = "LocationSeries" - let (|AggregateId|) id = Equinox.AggregateId(categoryId, LocationId.toString id) + let [] category = "LocationSeries" + let (|For|) id = Equinox.AggregateId(category, LocationId.toString id) -module Folds = +module Fold = type State = LocationEpochId let initial = LocationEpochId.parse -1 @@ -20,26 +20,25 @@ module Folds = | Events.Started e -> e.epochId let fold = Seq.fold evolve -let interpretActivateEpoch epochId (state : Folds.State) = +let interpretActivateEpoch epochId (state : Fold.State) = [if state < epochId then yield Events.Started { epochId = epochId }] let toActiveEpoch state = - if state = Folds.initial then None else Some state + if state = Fold.initial then None else Some state -type Service internal (resolve, ?maxAttempts) = +type Service internal (log, resolve, maxAttempts) = - let log = Serilog.Log.ForContext() - let (|Stream|) (Events.AggregateId id) = Equinox.Stream(log, resolve id, maxAttempts = defaultArg maxAttempts 2) + let resolve (Events.For id) = Equinox.Stream(log, resolve id, maxAttempts) member __.Read(locationId) : Async = - let (Stream stream) = locationId + let stream = resolve locationId stream.Query toActiveEpoch - member __.ActivateEpoch(locationId,epochId) : Async = - let (Stream stream) = locationId + member __.ActivateEpoch(locationId, epochId) : Async = + let stream = resolve locationId stream.Transact(interpretActivateEpoch epochId) -let create resolve maxAttempts = Service(resolve, maxAttempts) +let create resolve maxAttempts = Service(Serilog.Log.ForContext(), resolve, maxAttempts) module Cosmos = @@ -47,6 +46,6 @@ module Cosmos = let resolve (context,cache) = let cacheStrategy = CachingStrategy.SlidingWindow (cache, System.TimeSpan.FromMinutes 20.) let opt = Equinox.ResolveOption.AllowStale - fun id -> Resolver(context, Events.codec, Folds.fold, Folds.initial, cacheStrategy, AccessStrategy.LatestKnownEvent).Resolve(id,opt) + fun id -> Resolver(context, Events.codec, Fold.fold, Fold.initial, cacheStrategy, AccessStrategy.LatestKnownEvent).Resolve(id,opt) let createService (context, cache, maxAttempts) = create (resolve (context,cache)) maxAttempts \ No newline at end of file From d8dfa0e6a438f67f0e3c2d6d7e281a6ea13ef3c2 Mon Sep 17 00:00:00 2001 From: Ruben Bartelink Date: Fri, 17 Jan 2020 12:30:04 +0000 Subject: [PATCH 10/51] Sync layout --- equinox-fc/Domain/LocationEpoch.fs | 9 +++++---- equinox-fc/Domain/LocationSeries.fs | 7 ++++--- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/equinox-fc/Domain/LocationEpoch.fs b/equinox-fc/Domain/LocationEpoch.fs index e2f41d27f..e5c0f571d 100644 --- a/equinox-fc/Domain/LocationEpoch.fs +++ b/equinox-fc/Domain/LocationEpoch.fs @@ -4,6 +4,11 @@ module Location.Epoch [] module Events = + let [] category = "LocationEpoch" + let (|For|) (locationId, epochId) = + let id = sprintf "%s_%s" (LocationId.toString locationId) (LocationEpochId.toString epochId) + Equinox.AggregateId (category, id) + type CarriedForward = { initial : int } type Delta = { value : int } type Event = @@ -12,10 +17,6 @@ module Events = | Delta of Delta interface TypeShape.UnionContract.IUnionContract let codec = FsCodec.NewtonsoftJson.Codec.Create() - let [] category = "LocationEpoch" - let (|For|) (locationId, epochId) = - let id = sprintf "%s_%s" (LocationId.toString locationId) (LocationEpochId.toString epochId) - Equinox.AggregateId(category, id) module Fold = diff --git a/equinox-fc/Domain/LocationSeries.fs b/equinox-fc/Domain/LocationSeries.fs index 7c7d1e6c8..d1ff8972e 100644 --- a/equinox-fc/Domain/LocationSeries.fs +++ b/equinox-fc/Domain/LocationSeries.fs @@ -4,19 +4,20 @@ module Location.Series [] module Events = + let [] category = "LocationSeries" + let (|For|) id = Equinox.AggregateId (category, LocationId.toString id) + type Started = { epochId : LocationEpochId } type Event = | Started of Started interface TypeShape.UnionContract.IUnionContract let codec = FsCodec.NewtonsoftJson.Codec.Create() - let [] category = "LocationSeries" - let (|For|) id = Equinox.AggregateId(category, LocationId.toString id) module Fold = type State = LocationEpochId let initial = LocationEpochId.parse -1 - let evolve _state = function + let private evolve _state = function | Events.Started e -> e.epochId let fold = Seq.fold evolve From 4f599a3e8e875c5fb6b41be7bab0ed7c5f3c6f60 Mon Sep 17 00:00:00 2001 From: Ruben Bartelink Date: Wed, 12 Feb 2020 10:26:41 +0000 Subject: [PATCH 11/51] wip --- dotnet-templates.sln | 19 ++++ equinox-fc/.template.config/template.json | 5 +- .../Domain.Tests/LocationSeriesTests.fs | 4 +- equinox-fc/Domain/Domain.fsproj | 3 + equinox-fc/Domain/Infrastructure.fs | 21 ++++- equinox-fc/Domain/Inventory.fs | 83 +++++++++++++++++ equinox-fc/Domain/InventoryEpoch.fs | 93 +++++++++++++++++++ equinox-fc/Domain/InventorySeries.fs | 66 +++++++++++++ equinox-fc/Domain/Location.fs | 6 +- equinox-fc/Domain/LocationEpoch.fs | 7 +- equinox-fc/Domain/LocationSeries.fs | 15 ++- 11 files changed, 303 insertions(+), 19 deletions(-) create mode 100644 equinox-fc/Domain/Inventory.fs create mode 100644 equinox-fc/Domain/InventoryEpoch.fs create mode 100644 equinox-fc/Domain/InventorySeries.fs diff --git a/dotnet-templates.sln b/dotnet-templates.sln index 950b0b6b6..780cc9ce9 100644 --- a/dotnet-templates.sln +++ b/dotnet-templates.sln @@ -97,6 +97,15 @@ Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Domain.Tests", "equinox-shi EndProject Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Watchdog.Integration", "equinox-shipping\Watchdog.Integration\Watchdog.Integration.fsproj", "{83BA87C3-6288-40F4-BC4F-EC3A54586CDF}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "equinox-fc", "equinox-fc", "{4946576F-1558-49ED-A272-6F4D92FB0031}" +ProjectSection(SolutionItems) = preProject + equinox-fc\.template.config\template.json = equinox-fc\.template.config\template.json +EndProjectSection +EndProject +Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Domain", "equinox-fc\Domain\Domain.fsproj", "{B3CFC965-6AB9-47E8-AA47-548A8D8A2E2C}" +EndProject +Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Domain.Tests", "equinox-fc\Domain.Tests\Domain.Tests.fsproj", "{49890A45-D6C2-4EF6-87AD-39960E03E254}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -157,6 +166,14 @@ Global {9AFF6138-B63B-4EBF-B86B-4F626E1F1ADF}.Debug|Any CPU.Build.0 = Debug|Any CPU {9AFF6138-B63B-4EBF-B86B-4F626E1F1ADF}.Release|Any CPU.ActiveCfg = Release|Any CPU {9AFF6138-B63B-4EBF-B86B-4F626E1F1ADF}.Release|Any CPU.Build.0 = Release|Any CPU + {B3CFC965-6AB9-47E8-AA47-548A8D8A2E2C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B3CFC965-6AB9-47E8-AA47-548A8D8A2E2C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B3CFC965-6AB9-47E8-AA47-548A8D8A2E2C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B3CFC965-6AB9-47E8-AA47-548A8D8A2E2C}.Release|Any CPU.Build.0 = Release|Any CPU + {49890A45-D6C2-4EF6-87AD-39960E03E254}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {49890A45-D6C2-4EF6-87AD-39960E03E254}.Debug|Any CPU.Build.0 = Debug|Any CPU + {49890A45-D6C2-4EF6-87AD-39960E03E254}.Release|Any CPU.ActiveCfg = Release|Any CPU + {49890A45-D6C2-4EF6-87AD-39960E03E254}.Release|Any CPU.Build.0 = Release|Any CPU {26BFE6BC-5887-4E40-8CFD-F15332F5A104}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {26BFE6BC-5887-4E40-8CFD-F15332F5A104}.Debug|Any CPU.Build.0 = Debug|Any CPU {26BFE6BC-5887-4E40-8CFD-F15332F5A104}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -182,6 +199,8 @@ Global {D7ACBDF8-5F24-420F-9657-20096CE08B49} = {818D28A6-E6AB-4416-BDA6-1577C5D54447} {B6389F9E-A8E4-4BD7-B4C0-703B1A69BEA1} = {E7434881-8655-4C22-82CD-91ADB5123A73} {36C2D70A-F292-4481-8ADA-5066A80F92B2} = {1F3C9245-F973-43A3-97C9-5E527B93060C} + {B3CFC965-6AB9-47E8-AA47-548A8D8A2E2C} = {4946576F-1558-49ED-A272-6F4D92FB0031} + {49890A45-D6C2-4EF6-87AD-39960E03E254} = {4946576F-1558-49ED-A272-6F4D92FB0031} {7B96FCF8-0BB5-4494-A143-628882A6E50A} = {DAE9E2B9-EDA2-4064-B0CE-FD5294549374} {9AFF6138-B63B-4EBF-B86B-4F626E1F1ADF} = {DAE9E2B9-EDA2-4064-B0CE-FD5294549374} {5A45EF21-576B-4B40-86BD-F5960ECD66BF} = {DAE9E2B9-EDA2-4064-B0CE-FD5294549374} diff --git a/equinox-fc/.template.config/template.json b/equinox-fc/.template.config/template.json index 20972bd61..135b57883 100644 --- a/equinox-fc/.template.config/template.json +++ b/equinox-fc/.template.config/template.json @@ -4,14 +4,15 @@ "classifications": [ "Equinox", "Event Sourcing", - "Fc" + "Fc", + "Propulsion" ], "tags": { "language": "F#" }, "identity": "Equinox.Fc", "name": "Equinox Fc Example", - "shortName": "eqxfc", + "shortName": "eqxFc", "sourceName": "Fc", "preferNameDirectory": true } \ No newline at end of file diff --git a/equinox-fc/Domain.Tests/LocationSeriesTests.fs b/equinox-fc/Domain.Tests/LocationSeriesTests.fs index b0ec09dd7..4335d8128 100644 --- a/equinox-fc/Domain.Tests/LocationSeriesTests.fs +++ b/equinox-fc/Domain.Tests/LocationSeriesTests.fs @@ -9,7 +9,7 @@ let [] properties c1 c2 = let events = interpretActivateEpoch c1 Fold.initial let state1 = Fold.fold Fold.initial events let epoch0 = %0 - match c1, events, toActiveEpoch state1 with + match c1, events, state1 with // Started events are not written for < 0 | n, [], activeEpoch when n < epoch0 -> test <@ None = activeEpoch @> @@ -22,7 +22,7 @@ let [] properties c1 c2 = let events = interpretActivateEpoch c2 state1 let state2 = Fold.fold state1 events - match toActiveEpoch state1, c2, events, toActiveEpoch state2 with + match state1, c2, events, state2 with // Started events are not written for < 0 | None, n, [], activeEpoch when n < epoch0 -> test <@ None = activeEpoch @> diff --git a/equinox-fc/Domain/Domain.fsproj b/equinox-fc/Domain/Domain.fsproj index 107408957..a3c779a4a 100644 --- a/equinox-fc/Domain/Domain.fsproj +++ b/equinox-fc/Domain/Domain.fsproj @@ -11,6 +11,9 @@ + + + diff --git a/equinox-fc/Domain/Infrastructure.fs b/equinox-fc/Domain/Infrastructure.fs index 7a1bfa843..945811c5d 100644 --- a/equinox-fc/Domain/Infrastructure.fs +++ b/equinox-fc/Domain/Infrastructure.fs @@ -13,4 +13,23 @@ and [] locationEpochId module LocationEpochId = let parse (value : int) : LocationEpochId = %value let next (value : LocationEpochId) : LocationEpochId = % (%value + 1) - let toString (value : LocationEpochId) : string = string %value \ No newline at end of file + let toString (value : LocationEpochId) : string = string %value + +type InventoryId = string +and [] inventoryId +module InventoryId = + let parse (value : string) : InventoryId = %value + let toString (value : InventoryId) : string = %value + +type InventoryEpochId = int +and [] inventoryEpochId +module InventoryEpochId = + let parse (value : int) : InventoryEpochId = %value + let next (value : InventoryEpochId) : InventoryEpochId = % (%value + 1) + let toString (value : InventoryEpochId) : string = string %value + +type InventoryTransactionId = string +and [] inventoryTransactionId +module InventoryTransactionId = + let parse (value : string) : InventoryTransactionId = %value + let toString (value : InventoryTransactionId) : string = %value \ No newline at end of file diff --git a/equinox-fc/Domain/Inventory.fs b/equinox-fc/Domain/Inventory.fs new file mode 100644 index 000000000..f587010fa --- /dev/null +++ b/equinox-fc/Domain/Inventory.fs @@ -0,0 +1,83 @@ +namespace Domain.Inventory + +open Equinox.Core // we use Equinox's AsyncCacheCell helper below +open FSharp.UMX + +type internal TicketsCache() = + let all = System.Collections.Concurrent.ConcurrentDictionary() // Bounded only by relatively low number of physical pick tickets IRL + static member Create init = let x = TicketsCache() in x.Add init; x + member __.Add tickets = for x in tickets do all.[x] <- () + member __.Contains ticket = all.ContainsKey ticket + +/// Maintains active BatchId in a thread-safe manner while ingesting items into the chain of `batches` indexed by the `transmissions` +/// Prior to first add, reads `lookBack` batches to seed the cache, in order to minimize the number of duplicated pickTickets we transmit +type Ingester internal (fcId, batches : Batch.Service, transmissions : Transmissions.Service, lookBack, capacity) = + + let log = Serilog.Log.ForContext() + // Maintains what we believe to be the currently open BatchId. + // Guaranteed to be set only after `previousTicket.AwaitValue()` + let mutable activeId = Unchecked.defaultof<_> + + // We want max one request in flight to establish the pre-existing Batches from which the tickets cache will be seeded + let previousBatches = AsyncCacheCell list> <| async { + let! startingId = transmissions.ReadIngestionBatchId(fcId) + activeId <- %startingId + let readBatch batchId = async { let! r = batches.IngestShipped(fcId,batchId,(fun _ -> 1),Seq.empty) in return r.batchContent } + return [ for b in (max 0 (%startingId - lookBack)) .. (%startingId - 1) -> AsyncCacheCell(readBatch %b) ] } + + // Tickets cache - used to maintain a list of tickets that have already been ingested in order to avoid db round-trips + let previousTickets : AsyncCacheCell = AsyncCacheCell <| async { + let! batches = previousBatches.AwaitValue() + let! tickets = seq { for x in batches -> x.AwaitValue() } |> Async.Parallel + return TicketsCache.Create(Seq.concat tickets) } + + let tryIngest items = async { + let! previousTickets = previousTickets.AwaitValue() + let firstBatchId = %activeId + + let rec aux batchId totalIngestedTickets items = async { + let dup,fresh = items |> List.partition previousTickets.Contains + let fullCount = List.length items + let dropping = fullCount - List.length fresh + if dropping <> 0 then log.Information("Ignoring {count}/{fullCount} duplicate ids: {ids} for {batchId}", dropping, fullCount, dup, batchId) + if List.isEmpty fresh then + return totalIngestedTickets + else + let! res = batches.IngestShipped(fcId,batchId,capacity,fresh) + log.Information("Added {count} items to {fcId:l}/{batchId}", res.added, fcId, batchId) + // The adding is potentially redundant; we don't care + previousTickets.Add res.batchContent + // Any writer noticing we've moved to a new batch shares the burden of marking it active + if not res.isClosed && activeId < %batchId then + log.Information("Marking {fcId:l}/{batchId} active", fcId, batchId) + do! transmissions.MarkIngestionBatchId(fcId,batchId) + System.Threading.Interlocked.CompareExchange(&activeId,%batchId,activeId) |> ignore + let totalIngestedTickets = totalIngestedTickets + res.added + match res.rejected with + | [] -> return totalIngestedTickets + | rej -> return! aux (BatchId.next batchId) totalIngestedTickets rej } + return! aux firstBatchId 0 items + } + + /// Upon startup, we initialize the PickTickets cache with recent batches; we want to kick that process off before our first ingest + member __.Initialize() = previousBatches.AwaitValue() |> Async.Ignore + + /// Feeds the items into the sequence of batches. Returns the number of items actually added [excluding duplicates] + member __.Ingest(items : PickTicketId list) : Async = tryIngest items + +module PickTicketsIngester = + + let create fcId maxPickTicketsPerBatch lookBackLimit batchesResolve (epochOverride,transmissionsResolve) = + let remainingBatchCapacity (state: Financial.Batch.Fold.State) = + let l = state.ItemCount + max 0 (maxPickTicketsPerBatch-l) + let batches = Financial.Batch.createService batchesResolve + let transmissions = Financial.Transmissions.createService epochOverride transmissionsResolve + Ingester(fcId, batches, transmissions, lookBack=lookBackLimit, capacity=remainingBatchCapacity) + + module Cosmos = + + let create epochOverride fcId maxPickTicketsPerBatch lookBackLimit (context,cache) = + let batchesResolve = Financial.Batch.Cosmos.resolve (context,cache) + let transmissionsResolve = Financial.Transmissions.Cosmos.resolve (context,cache) + create fcId maxPickTicketsPerBatch lookBackLimit batchesResolve (epochOverride,transmissionsResolve) diff --git a/equinox-fc/Domain/InventoryEpoch.fs b/equinox-fc/Domain/InventoryEpoch.fs new file mode 100644 index 000000000..f78bdd25c --- /dev/null +++ b/equinox-fc/Domain/InventoryEpoch.fs @@ -0,0 +1,93 @@ +module Domain.Inventory.Epoch + +open System + +// NB - these types and the union case names reflect the actual storage formats and hence need to be versioned with care +[] +module Events = + + let [] categoryId = "InventoryEpoch" + let (|For|) (inventoryId : InventoryId, epochId : InventoryEpochId) = + let streamId = sprintf "%s_%s" (InventoryId.toString inventoryId) (InventoryEpochId.toString epochId) + Equinox.AggregateId(categoryId, streamId) + + type Transferred = { transactionId : InventoryTransactionId } + type Adjusted = { transactionId : InventoryTransactionId } + + type Snapshotted = { closed: bool; ids : InventoryTransactionId[] } + + type Event = + | Adjusted of Adjusted + | Transferred of Transferred + | Closed + | Snapshotted of Snapshotted + interface TypeShape.UnionContract.IUnionContract + let codec = FsCodec.NewtonsoftJson.Codec.Create() + // TOCONSIDER - codec could pull transferId from meta + +module Fold = + + type State = { closed : bool; ids : Set } + let initial = { closed = false; ids = Set.empty } + let evolve state = function + | Events.Adjusted e -> { state with ids = Set.add e.transactionId state.ids } + | Events.Transferred e -> { state with ids = Set.add e.transactionId state.ids } + | Events.Closed -> { state with closed = true } + | Events.Snapshotted e -> { closed = e.closed; ids = Set.ofArray e.ids } + let fold : State -> Events.Event seq -> State = Seq.fold evolve + let isOrigin = function Events.Snapshotted _ -> true | _ -> false + let snapshot s = Events.Snapshotted { closed = s.closed; ids = Array.ofSeq s.ids } + +type Result = + { /// Indicates whether this epoch is closed (either previously or as a side-effect this time) + isClosed : bool + /// Count of items added to this epoch. May be less than requested due to removal of duplicates and/or rejected items + added : int + /// residual items that [are not duplicates and] were not accepted into this epoch + rejected : Events.Event list } + +let decideSync capacity requested (state : Fold.State) : Result * Events.Event list = + let isFresh = function + | Events.Adjusted { transactionId = id } + | Events.Transferred { transactionId = id } -> (not << state.ids.Contains) id + | Events.Closed | Events.Snapshotted _ -> false + let news = requested |> Seq.filter isFresh |> List.ofSeq + let closed,allowing,markClosed,residual = + let newCount = List.length news + if state.closed then + true,0,false,news + else + let capacityNow = capacity state + let accepting = min capacityNow newCount + let closing = accepting = capacityNow + let residual = List.skip accepting news + closing,accepting,closing,residual + let events = + [ if allowing <> 0 then yield! news + if markClosed then yield Events.Closed ] + let state' = Fold.fold state events + { isClosed = closed; added = allowing; rejected = residual },events + +type Service internal (log, resolve, maxAttempts) = + + let resolve (Events.For streamId) = Equinox.Stream(log, resolve streamId, maxAttempts) + + /// Attempt ingestion of `events` into the cited Epoch. + /// - No `items` will be accepted if the Epoch is `closed` + /// - The `capacity` function will be passed a non-closed `state` in order to determine number of items that can be admitted prior to closing + /// - If the computed capacity result is >= the number of items being submitted (which may be 0), the Epoch will be marked Closed + /// NOTE the result may include rejected items (which the caller is expected to feed into a successor epoch) + member __.IngestShipped(inventoryId, epochId, capacity, events) : Async = + let stream = resolve (inventoryId, epochId) + stream.Transact(decideSync capacity events) + +let createService resolve = Service(Serilog.Log.ForContext(), resolve, maxAttempts = 2) + +module Cosmos = + + open Equinox.Cosmos + let accessStrategy = Equinox.Cosmos.AccessStrategy.Snapshot (Fold.isOrigin, Fold.snapshot) + let resolve (context,cache) = + let cacheStrategy = CachingStrategy.SlidingWindow (cache, TimeSpan.FromMinutes 20.) + Resolver(context, Events.codec, Fold.fold, Fold.initial, cacheStrategy, accessStrategy).Resolve + let createService (context,cache) = createService (resolve (context,cache)) \ No newline at end of file diff --git a/equinox-fc/Domain/InventorySeries.fs b/equinox-fc/Domain/InventorySeries.fs new file mode 100644 index 000000000..a70c8abb0 --- /dev/null +++ b/equinox-fc/Domain/InventorySeries.fs @@ -0,0 +1,66 @@ +module Domain.Inventory.Series + +// NB - these types and the union case names reflect the actual storage formats and hence need to be versioned with care +[] +module Events = + + let [] categoryId = "InventorySeries" + let (|For|) inventoryId = Equinox.AggregateId(categoryId, InventoryId.toString inventoryId) + + type Epoch = { epoch : InventoryEpochId } + type Checkpoint = { epoch : InventoryEpochId; index : int64 } + type Snapshotted = { writing : InventoryEpochId; checkpoint : Checkpoint option } + type Event = + | Started of Epoch + | Checkpointed of Checkpoint + | Snapshotted of Snapshotted + interface TypeShape.UnionContract.IUnionContract + let codec = FsCodec.NewtonsoftJson.Codec.Create() + +module Fold = + + type State = { writing : InventoryEpochId; checkpoint : Events.Checkpoint option } + + let initial = { writing = 0; checkpoint = None } + let evolve state = function + | Events.Started e -> { state with writing = e.epoch } + | Events.Checkpointed e -> { state with checkpoint = Some e } + | Events.Snapshotted e -> { writing = e.writing; checkpoint = e.checkpoint } + let fold : State -> Events.Event seq -> State = Seq.fold evolve + + let isOrigin = function Events.Snapshotted _ -> true | _ -> false + let snapshot s = Events.Snapshotted { writing = s.writing; checkpoint = s.checkpoint } + // TODO transmute to remove checkpoints (a la propulsion?) + +let interpretMarkWriting epochId (state : Fold.State) = + if state.writing >= epochId then [] + else [Events.Started { epoch = epochId }] + +type Service internal (log, resolve, maxAttempts) = + + let resolve (Events.For streamId) = Equinox.Stream(log, resolve streamId, maxAttempts) + + member __.ReadIngestionEpochId inventoryId : Async = + let stream = resolve inventoryId + stream.Query(fun s -> s.writing) + + member __.UpdateEpoch(inventoryId, epochId) : Async = + let stream = resolve inventoryId + stream.Transact(interpretMarkWriting epochId) + +let createService resolve = + Service(Serilog.Log.ForContext(), resolve, maxAttempts = 2) + +module Cosmos = + + open Equinox.Cosmos + let resolve (context,cache) = + let cacheStrategy = CachingStrategy.SlidingWindow (cache, System.TimeSpan.FromMinutes 20.) + // For this stream, we uniformly use stale reads as: + // a) we don't require any information from competing writers + // b) while there are competing writers [which might cause us to have to retry a Transact], this should be infrequent + let opt = Equinox.ResolveOption.AllowStale + let accessStrategy = AccessStrategy.Snapshot(Fold.isOrigin,Fold.snapshot) + fun id -> Resolver(context, Events.codec, Fold.fold, Fold.initial, cacheStrategy, accessStrategy).Resolve(id,opt) + let createService (context,cache) = + createService (resolve (context,cache)) diff --git a/equinox-fc/Domain/Location.fs b/equinox-fc/Domain/Location.fs index 7e4468f58..86dd213dc 100644 --- a/equinox-fc/Domain/Location.fs +++ b/equinox-fc/Domain/Location.fs @@ -6,7 +6,7 @@ type Wip<'R> = | Complete of 'R /// Manages a Series of Epochs, with a running total being carried forward to the next Epoch when it's Closed -type LocationService internal (zeroBalance, shouldClose, series : Series.Service, epochs : Epoch.Service) = +type Service internal (zeroBalance, shouldClose, series : Series.Service, epochs : Epoch.Service) = let rec execute locationId originEpochId = let rec aux epochId balanceToCarryForward wip = async { @@ -25,7 +25,7 @@ type LocationService internal (zeroBalance, shouldClose, series : Series.Service aux member __.Execute(locationId, decide) = async { - let! activeEpoch = series.Read locationId + let! activeEpoch = series.ReadIngestionEpoch locationId let originEpochId, epochId, balanceCarriedForward = match activeEpoch with | None -> LocationEpochId.parse -1, LocationEpochId.parse 0, Some zeroBalance @@ -36,7 +36,7 @@ type LocationService internal (zeroBalance, shouldClose, series : Series.Service module Helpers = let create (zeroBalance, shouldClose) (series, epochs) = - LocationService(zeroBalance, shouldClose, series, epochs) + Service(zeroBalance, shouldClose, series, epochs) module Cosmos = diff --git a/equinox-fc/Domain/LocationEpoch.fs b/equinox-fc/Domain/LocationEpoch.fs index e5c0f571d..3189d8e3e 100644 --- a/equinox-fc/Domain/LocationEpoch.fs +++ b/equinox-fc/Domain/LocationEpoch.fs @@ -11,10 +11,12 @@ module Events = type CarriedForward = { initial : int } type Delta = { value : int } + type Value = { value : int } type Event = | CarriedForward of CarriedForward | Closed | Delta of Delta + | Reset of Value interface TypeShape.UnionContract.IUnionContract let codec = FsCodec.NewtonsoftJson.Codec.Create() @@ -28,9 +30,10 @@ module Fold = match event, state with | Events.CarriedForward e, Initial -> Open { count = 0; value = e.initial } | Events.Delta e, Open bal -> Open { count = bal.count + 1; value = bal.value + e.value } + | Events.Reset e, Open bal -> Open { count = bal.count + 1; value = e.value } | Events.Closed, Open { value = bal } -> Closed bal | Events.CarriedForward _, (Open _|Closed _ as x) -> failwithf "CarriedForward : Unexpected %A" x - | Events.Delta _, (Initial|Closed _ as x) -> failwithf "Delta : Unexpected %A" x + | (Events.Delta _|Events.Reset _), (Initial|Closed _ as x) -> failwithf "Delta : Unexpected %A" x | Events.Closed, (Initial|Closed _ as x) -> failwithf "Closed : Unexpected %A" x let fold = Seq.fold evolve @@ -47,7 +50,7 @@ type Result<'t> = { balance : Fold.Balance; result : 't option; isOpen : bool } let sync (balanceCarriedForward : Fold.Balance option) (decide : (Fold.Balance -> 't*Events.Event list)) shouldClose state : Result<'t>*Events.Event list = let acc = Accumulator() - // We always want to have a CarriedForward event at the start of any Epoch's event stream + // We require a CarriedForward event at the start of any Epoch's event stream let (), state = acc.Ingest state <| match state with diff --git a/equinox-fc/Domain/LocationSeries.fs b/equinox-fc/Domain/LocationSeries.fs index d1ff8972e..f81aecff7 100644 --- a/equinox-fc/Domain/LocationSeries.fs +++ b/equinox-fc/Domain/LocationSeries.fs @@ -15,25 +15,22 @@ module Events = module Fold = - type State = LocationEpochId - let initial = LocationEpochId.parse -1 + type State = LocationEpochId option + let initial = None let private evolve _state = function - | Events.Started e -> e.epochId + | Events.Started e -> Some e.epochId let fold = Seq.fold evolve let interpretActivateEpoch epochId (state : Fold.State) = - [if state < epochId then yield Events.Started { epochId = epochId }] - -let toActiveEpoch state = - if state = Fold.initial then None else Some state + [if state |> Option.forall (fun s -> s < epochId) then yield Events.Started { epochId = epochId }] type Service internal (log, resolve, maxAttempts) = let resolve (Events.For id) = Equinox.Stream(log, resolve id, maxAttempts) - member __.Read(locationId) : Async = + member __.ReadIngestionEpoch(locationId) : Async = let stream = resolve locationId - stream.Query toActiveEpoch + stream.Query id member __.ActivateEpoch(locationId, epochId) : Async = let stream = resolve locationId From ce36a0903bfcbc2736d76cacf3c87e689091eba2 Mon Sep 17 00:00:00 2001 From: Ruben Bartelink Date: Wed, 12 Feb 2020 17:08:02 +0000 Subject: [PATCH 12/51] wip --- equinox-fc/Domain.Tests/Domain.Tests.fsproj | 2 +- equinox-fc/Domain/Domain.fsproj | 4 +- equinox-fc/Domain/Infrastructure.fs | 5 + equinox-fc/Domain/Inventory.fs | 26 ++--- equinox-fc/Domain/InventoryEpoch.fs | 19 ++-- equinox-fc/Domain/InventorySeries.fs | 18 ++-- equinox-fc/Domain/InventoryTransaction.fs | 114 ++++++++++++++++++++ equinox-fc/Domain/Location.fs | 4 +- equinox-fc/Domain/LocationEpoch.fs | 21 ++-- equinox-fc/Domain/LocationSeries.fs | 6 +- 10 files changed, 167 insertions(+), 52 deletions(-) create mode 100644 equinox-fc/Domain/InventoryTransaction.fs diff --git a/equinox-fc/Domain.Tests/Domain.Tests.fsproj b/equinox-fc/Domain.Tests/Domain.Tests.fsproj index 8ec512e47..40feab0a9 100644 --- a/equinox-fc/Domain.Tests/Domain.Tests.fsproj +++ b/equinox-fc/Domain.Tests/Domain.Tests.fsproj @@ -16,7 +16,7 @@ - + diff --git a/equinox-fc/Domain/Domain.fsproj b/equinox-fc/Domain/Domain.fsproj index a3c779a4a..496064bdd 100644 --- a/equinox-fc/Domain/Domain.fsproj +++ b/equinox-fc/Domain/Domain.fsproj @@ -14,10 +14,10 @@ + - - + diff --git a/equinox-fc/Domain/Infrastructure.fs b/equinox-fc/Domain/Infrastructure.fs index 945811c5d..0ef31b200 100644 --- a/equinox-fc/Domain/Infrastructure.fs +++ b/equinox-fc/Domain/Infrastructure.fs @@ -6,12 +6,14 @@ type LocationId = string and [] locationId module LocationId = let parse (value : string) : LocationId = %value + let (|Parse|) = parse let toString (value : LocationId) : string = %value type LocationEpochId = int and [] locationEpochId module LocationEpochId = let parse (value : int) : LocationEpochId = %value + let (|Parse|) = parse let next (value : LocationEpochId) : LocationEpochId = % (%value + 1) let toString (value : LocationEpochId) : string = string %value @@ -19,12 +21,14 @@ type InventoryId = string and [] inventoryId module InventoryId = let parse (value : string) : InventoryId = %value + let (|Parse|) = parse let toString (value : InventoryId) : string = %value type InventoryEpochId = int and [] inventoryEpochId module InventoryEpochId = let parse (value : int) : InventoryEpochId = %value + let (|Parse|) = parse let next (value : InventoryEpochId) : InventoryEpochId = % (%value + 1) let toString (value : InventoryEpochId) : string = string %value @@ -32,4 +36,5 @@ type InventoryTransactionId = string and [] inventoryTransactionId module InventoryTransactionId = let parse (value : string) : InventoryTransactionId = %value + let (|Parse|) = parse let toString (value : InventoryTransactionId) : string = %value \ No newline at end of file diff --git a/equinox-fc/Domain/Inventory.fs b/equinox-fc/Domain/Inventory.fs index f587010fa..d68cb3cfe 100644 --- a/equinox-fc/Domain/Inventory.fs +++ b/equinox-fc/Domain/Inventory.fs @@ -1,21 +1,21 @@ -namespace Domain.Inventory +namespace Fc.Inventory open Equinox.Core // we use Equinox's AsyncCacheCell helper below open FSharp.UMX -type internal TicketsCache() = - let all = System.Collections.Concurrent.ConcurrentDictionary() // Bounded only by relatively low number of physical pick tickets IRL - static member Create init = let x = TicketsCache() in x.Add init; x - member __.Add tickets = for x in tickets do all.[x] <- () - member __.Contains ticket = all.ContainsKey ticket +type internal IdsCache<'Id>() = + let all = System.Collections.Concurrent.ConcurrentDictionary<'Id,unit>() // Bounded only by relatively low number of physical pick tickets IRL + static member Create init = let x = IdsCache() in x.Add init; x + member __.Add ids = for x in ids do all.[x] <- () + member __.Contains id = all.ContainsKey id /// Maintains active BatchId in a thread-safe manner while ingesting items into the chain of `batches` indexed by the `transmissions` /// Prior to first add, reads `lookBack` batches to seed the cache, in order to minimize the number of duplicated pickTickets we transmit -type Ingester internal (fcId, batches : Batch.Service, transmissions : Transmissions.Service, lookBack, capacity) = +type Ingester internal (fcId, series : Series.Service, epochs : Epoch.Service, lookBack, capacity) = let log = Serilog.Log.ForContext() - // Maintains what we believe to be the currently open BatchId. - // Guaranteed to be set only after `previousTicket.AwaitValue()` + // Maintains what we believe to be the currently open EpochId + // Guaranteed to be set only after `previousIds.AwaitValue()` let mutable activeId = Unchecked.defaultof<_> // We want max one request in flight to establish the pre-existing Batches from which the tickets cache will be seeded @@ -26,13 +26,13 @@ type Ingester internal (fcId, batches : Batch.Service, transmissions : Transmiss return [ for b in (max 0 (%startingId - lookBack)) .. (%startingId - 1) -> AsyncCacheCell(readBatch %b) ] } // Tickets cache - used to maintain a list of tickets that have already been ingested in order to avoid db round-trips - let previousTickets : AsyncCacheCell = AsyncCacheCell <| async { + let previousIds : AsyncCacheCell = AsyncCacheCell <| async { let! batches = previousBatches.AwaitValue() - let! tickets = seq { for x in batches -> x.AwaitValue() } |> Async.Parallel - return TicketsCache.Create(Seq.concat tickets) } + let! ids = seq { for x in batches -> x.AwaitValue() } |> Async.Parallel + return IdsCache.Create(Seq.concat ids) } let tryIngest items = async { - let! previousTickets = previousTickets.AwaitValue() + let! previousTickets = previousIds.AwaitValue() let firstBatchId = %activeId let rec aux batchId totalIngestedTickets items = async { diff --git a/equinox-fc/Domain/InventoryEpoch.fs b/equinox-fc/Domain/InventoryEpoch.fs index f78bdd25c..2c0877a15 100644 --- a/equinox-fc/Domain/InventoryEpoch.fs +++ b/equinox-fc/Domain/InventoryEpoch.fs @@ -1,4 +1,4 @@ -module Domain.Inventory.Epoch +module Fc.Inventory.Epoch open System @@ -6,32 +6,27 @@ open System [] module Events = - let [] categoryId = "InventoryEpoch" - let (|For|) (inventoryId : InventoryId, epochId : InventoryEpochId) = - let streamId = sprintf "%s_%s" (InventoryId.toString inventoryId) (InventoryEpochId.toString epochId) - Equinox.AggregateId(categoryId, streamId) + let [] CategoryId = "InventoryEpoch" + let (|For|) (inventoryId, epochId) = FsCodec.StreamName.compose CategoryId [InventoryId.toString inventoryId; InventoryEpochId.toString epochId] - type Transferred = { transactionId : InventoryTransactionId } - type Adjusted = { transactionId : InventoryTransactionId } + type TransactionInfo = { transactionId : InventoryTransactionId } type Snapshotted = { closed: bool; ids : InventoryTransactionId[] } type Event = - | Adjusted of Adjusted - | Transferred of Transferred + | Adjusted of TransactionInfo + | Transferred of TransactionInfo | Closed | Snapshotted of Snapshotted interface TypeShape.UnionContract.IUnionContract let codec = FsCodec.NewtonsoftJson.Codec.Create() - // TOCONSIDER - codec could pull transferId from meta module Fold = type State = { closed : bool; ids : Set } let initial = { closed = false; ids = Set.empty } let evolve state = function - | Events.Adjusted e -> { state with ids = Set.add e.transactionId state.ids } - | Events.Transferred e -> { state with ids = Set.add e.transactionId state.ids } + | (Events.Adjusted e | Events.Transferred e) -> { state with ids = Set.add e.transactionId state.ids } | Events.Closed -> { state with closed = true } | Events.Snapshotted e -> { closed = e.closed; ids = Set.ofArray e.ids } let fold : State -> Events.Event seq -> State = Seq.fold evolve diff --git a/equinox-fc/Domain/InventorySeries.fs b/equinox-fc/Domain/InventorySeries.fs index a70c8abb0..20a25c7b5 100644 --- a/equinox-fc/Domain/InventorySeries.fs +++ b/equinox-fc/Domain/InventorySeries.fs @@ -1,11 +1,11 @@ -module Domain.Inventory.Series +module Fc.Inventory.Series // NB - these types and the union case names reflect the actual storage formats and hence need to be versioned with care [] module Events = - let [] categoryId = "InventorySeries" - let (|For|) inventoryId = Equinox.AggregateId(categoryId, InventoryId.toString inventoryId) + let [] CategoryId = "InventorySeries" + let (|For|) inventoryId = FsCodec.StreamName.create CategoryId (InventoryId.toString inventoryId) type Epoch = { epoch : InventoryEpochId } type Checkpoint = { epoch : InventoryEpochId; index : int64 } @@ -30,7 +30,7 @@ module Fold = let isOrigin = function Events.Snapshotted _ -> true | _ -> false let snapshot s = Events.Snapshotted { writing = s.writing; checkpoint = s.checkpoint } - // TODO transmute to remove checkpoints (a la propulsion?) + // TODO transmute to remove checkpoints a la Propulsion let interpretMarkWriting epochId (state : Fold.State) = if state.writing >= epochId then [] @@ -48,19 +48,21 @@ type Service internal (log, resolve, maxAttempts) = let stream = resolve inventoryId stream.Transact(interpretMarkWriting epochId) + // TODO checkpoint writing + let createService resolve = Service(Serilog.Log.ForContext(), resolve, maxAttempts = 2) module Cosmos = open Equinox.Cosmos - let resolve (context,cache) = + let resolve (context, cache) = let cacheStrategy = CachingStrategy.SlidingWindow (cache, System.TimeSpan.FromMinutes 20.) // For this stream, we uniformly use stale reads as: // a) we don't require any information from competing writers // b) while there are competing writers [which might cause us to have to retry a Transact], this should be infrequent let opt = Equinox.ResolveOption.AllowStale - let accessStrategy = AccessStrategy.Snapshot(Fold.isOrigin,Fold.snapshot) + let accessStrategy = AccessStrategy.Snapshot (Fold.isOrigin, Fold.snapshot) fun id -> Resolver(context, Events.codec, Fold.fold, Fold.initial, cacheStrategy, accessStrategy).Resolve(id,opt) - let createService (context,cache) = - createService (resolve (context,cache)) + let createService (context, cache) = + createService (resolve (context, cache)) diff --git a/equinox-fc/Domain/InventoryTransaction.fs b/equinox-fc/Domain/InventoryTransaction.fs new file mode 100644 index 000000000..e12ceb256 --- /dev/null +++ b/equinox-fc/Domain/InventoryTransaction.fs @@ -0,0 +1,114 @@ +module Fc.InventoryTransaction + +open System + +// NB - these types and the union case names reflect the actual storage formats and hence need to be versioned with care +[] +module Events = + + let [] CategoryId = "Transfer" + let (|For|) (inventoryId, transactionId) = + FsCodec.StreamName.compose CategoryId [InventoryId.toString inventoryId; InventoryTransactionId.toString transactionId] + + type TransferRequested = { source : LocationId; destination : LocationId; quantity : int } + type Removed = { balance : int } + type Added = { balance : int } + type AdjustmentRequested = { location : LocationId; quantity : int } + + type Event = + | AdjustmentRequested of AdjustmentRequested + | Adjusted + | TransferRequested of TransferRequested + | Failed + | Removed of Removed + | Added of Added + | Completed + interface TypeShape.UnionContract.IUnionContract + let codec = FsCodec.NewtonsoftJson.Codec.Create() + +module Fold = + + type Removed = { request : Events.TransferRequested; removed : Events.Removed } + type Added = { request : Events.TransferRequested; removed : Events.Removed; added : Events.Added } + type State = + | Initial + | Adjusting of Events.AdjustmentRequested + | Adjusted of Events.AdjustmentRequested + | Transferring of Events.TransferRequested + | Failed + | Adding of Removed + | Added of Added + | Completed + let initial = Initial + let evolve state = function + | Events.AdjustmentRequested e -> Adjusting e + | Events.Adjusted as ee -> + match state with + | Adjusting s -> Adjusted s + | x -> failwithf "Unexpected %A when %A " ee state + | Events.TransferRequested e -> Transferring e + | Events.Failed -> Failed + | Events.Removed e as ee -> + match state with + | Transferring s -> Adding { request = s; removed = e } + | x -> failwithf "Unexpected %A when %A " ee state + | Events.Added e as ee -> + match state with + | Adding s -> Added { request = s.request; removed = s.removed; added = e } + | x -> failwithf "Unexpected %A when %A " ee state + | Events.Completed -> Completed + let fold : State -> Events.Event seq -> State = Seq.fold evolve + +type Result = + { /// Indicates whether this epoch is closed (either previously or as a side-effect this time) + isClosed : bool + /// Count of items added to this epoch. May be less than requested due to removal of duplicates and/or rejected items + added : int + /// residual items that [are not duplicates and] were not accepted into this epoch + rejected : Events.Event list } + +let decideSync capacity requested (state : Fold.State) : Result * Events.Event list = + let isFresh = function + | Events.Adjusted { transactionId = id } + | Events.Transferred { transactionId = id } -> (not << state.ids.Contains) id + | Events.Closed | Events.Snapshotted _ -> false + let news = requested |> Seq.filter isFresh |> List.ofSeq + let closed,allowing,markClosed,residual = + let newCount = List.length news + if state.closed then + true,0,false,news + else + let capacityNow = capacity state + let accepting = min capacityNow newCount + let closing = accepting = capacityNow + let residual = List.skip accepting news + closing,accepting,closing,residual + let events = + [ if allowing <> 0 then yield! news + if markClosed then yield Events.Closed ] + let state' = Fold.fold state events + { isClosed = closed; added = allowing; rejected = residual },events + +type Service internal (log, resolve, maxAttempts) = + + let resolve (Events.For streamId) = Equinox.Stream(log, resolve streamId, maxAttempts) + + /// Attempt ingestion of `events` into the cited Epoch. + /// - No `items` will be accepted if the Epoch is `closed` + /// - The `capacity` function will be passed a non-closed `state` in order to determine number of items that can be admitted prior to closing + /// - If the computed capacity result is >= the number of items being submitted (which may be 0), the Epoch will be marked Closed + /// NOTE the result may include rejected items (which the caller is expected to feed into a successor epoch) + member __.IngestShipped(inventoryId, epochId, capacity, events) : Async = + let stream = resolve (inventoryId, epochId) + stream.Transact(decideSync capacity events) + +let createService resolve = Service(Serilog.Log.ForContext(), resolve, maxAttempts = 2) + +module Cosmos = + + open Equinox.Cosmos + let accessStrategy = Equinox.Cosmos.AccessStrategy.Snapshot (Fold.isOrigin, Fold.snapshot) + let resolve (context,cache) = + let cacheStrategy = CachingStrategy.SlidingWindow (cache, TimeSpan.FromMinutes 20.) + Resolver(context, Events.codec, Fold.fold, Fold.initial, cacheStrategy, accessStrategy).Resolve + let createService (context,cache) = createService (resolve (context,cache)) \ No newline at end of file diff --git a/equinox-fc/Domain/Location.fs b/equinox-fc/Domain/Location.fs index 86dd213dc..562e1da70 100644 --- a/equinox-fc/Domain/Location.fs +++ b/equinox-fc/Domain/Location.fs @@ -1,8 +1,8 @@ -namespace Location +namespace Fc.Location [] type Wip<'R> = - | Pending of decide : (Epoch.Fold.Balance -> 'R*Epoch.Events.Event list) + | Pending of decide : (Epoch.Fold.Balance -> 'R * Epoch.Events.Event list) | Complete of 'R /// Manages a Series of Epochs, with a running total being carried forward to the next Epoch when it's Closed diff --git a/equinox-fc/Domain/LocationEpoch.fs b/equinox-fc/Domain/LocationEpoch.fs index 3189d8e3e..9c92b2bc5 100644 --- a/equinox-fc/Domain/LocationEpoch.fs +++ b/equinox-fc/Domain/LocationEpoch.fs @@ -1,21 +1,20 @@ -module Location.Epoch +module Fc.Location.Epoch // NOTE - these types and the union case names reflect the actual storage formats and hence need to be versioned with care [] module Events = - let [] category = "LocationEpoch" - let (|For|) (locationId, epochId) = - let id = sprintf "%s_%s" (LocationId.toString locationId) (LocationEpochId.toString epochId) - Equinox.AggregateId (category, id) + let [] CategoryId = "LocationEpoch" + let (|For|) (locationId, epochId) = FsCodec.StreamName.compose CategoryId [LocationId.toString locationId; LocationEpochId.toString epochId] type CarriedForward = { initial : int } - type Delta = { value : int } - type Value = { value : int } + type Delta = { delta : int; transaction : InventoryTransactionId } + type Value = { value : int; transaction : InventoryTransactionId } type Event = | CarriedForward of CarriedForward | Closed - | Delta of Delta + | Added of Delta + | Removed of Delta | Reset of Value interface TypeShape.UnionContract.IUnionContract let codec = FsCodec.NewtonsoftJson.Codec.Create() @@ -29,12 +28,12 @@ module Fold = let evolve state event = match event, state with | Events.CarriedForward e, Initial -> Open { count = 0; value = e.initial } - | Events.Delta e, Open bal -> Open { count = bal.count + 1; value = bal.value + e.value } + | Events.Added e, Open bal -> Open { count = bal.count + 1; value = bal.value + e.delta } + | Events.Removed e, Open bal -> Open { count = bal.count + 1; value = bal.value - e.delta } | Events.Reset e, Open bal -> Open { count = bal.count + 1; value = e.value } | Events.Closed, Open { value = bal } -> Closed bal | Events.CarriedForward _, (Open _|Closed _ as x) -> failwithf "CarriedForward : Unexpected %A" x - | (Events.Delta _|Events.Reset _), (Initial|Closed _ as x) -> failwithf "Delta : Unexpected %A" x - | Events.Closed, (Initial|Closed _ as x) -> failwithf "Closed : Unexpected %A" x + | (Events.Added _|Events.Removed _|Events.Reset _|Events.Closed) as e, (Initial|Closed _ as s) -> failwithf "Unexpected %A when %A" e s let fold = Seq.fold evolve /// Holds events accumulated from a series of decisions while also evolving the presented `state` to reflect the pended events diff --git a/equinox-fc/Domain/LocationSeries.fs b/equinox-fc/Domain/LocationSeries.fs index f81aecff7..f69801b6a 100644 --- a/equinox-fc/Domain/LocationSeries.fs +++ b/equinox-fc/Domain/LocationSeries.fs @@ -1,11 +1,11 @@ -module Location.Series +module Fc.Location.Series // NOTE - these types and the union case names reflect the actual storage formats and hence need to be versioned with care [] module Events = - let [] category = "LocationSeries" - let (|For|) id = Equinox.AggregateId (category, LocationId.toString id) + let [] CategoryId = "LocationSeries" + let (|For|) id = FsCodec.StreamName.create CategoryId (LocationId.toString id) type Started = { epochId : LocationEpochId } type Event = From c7ef3d5c4ecad622ca5908885c22c59b4c4a1480 Mon Sep 17 00:00:00 2001 From: Ruben Bartelink Date: Thu, 13 Feb 2020 13:12:39 +0000 Subject: [PATCH 13/51] Complete Location, Inventory and LocationTests --- equinox-fc/Domain.Tests/Domain.Tests.fsproj | 2 +- equinox-fc/Domain.Tests/Infrastructure.fs | 5 +- equinox-fc/Domain.Tests/LocationEpochTests.fs | 29 +++--- .../Domain.Tests/LocationSeriesTests.fs | 6 +- equinox-fc/Domain.Tests/LocationTests.fs | 13 +-- equinox-fc/Domain/Domain.fsproj | 3 +- equinox-fc/Domain/Infrastructure.fs | 7 +- equinox-fc/Domain/Inventory.fs | 99 ++++++++++--------- equinox-fc/Domain/InventoryEpoch.fs | 28 +++--- equinox-fc/Domain/InventorySeries.fs | 11 ++- equinox-fc/Domain/InventoryTransaction.fs | 4 +- equinox-fc/Domain/Location.fs | 6 +- equinox-fc/Domain/LocationEpoch.fs | 10 +- equinox-fc/Domain/LocationSeries.fs | 17 ++-- 14 files changed, 127 insertions(+), 113 deletions(-) diff --git a/equinox-fc/Domain.Tests/Domain.Tests.fsproj b/equinox-fc/Domain.Tests/Domain.Tests.fsproj index 40feab0a9..078801f4e 100644 --- a/equinox-fc/Domain.Tests/Domain.Tests.fsproj +++ b/equinox-fc/Domain.Tests/Domain.Tests.fsproj @@ -1,7 +1,7 @@ - netcoreapp2.1 + netcoreapp3.1 5 Fc.Domain.Tests false diff --git a/equinox-fc/Domain.Tests/Infrastructure.fs b/equinox-fc/Domain.Tests/Infrastructure.fs index 66dd13d73..3f0d4f26e 100644 --- a/equinox-fc/Domain.Tests/Infrastructure.fs +++ b/equinox-fc/Domain.Tests/Infrastructure.fs @@ -1,5 +1,5 @@ [] -module Infrastructure +module Fc.Infrastructure open Serilog open System @@ -32,7 +32,8 @@ module Cosmos = /// Adapts the XUnit ITestOutputHelper to be a Serilog Sink type TestOutputAdapter(testOutput : Xunit.Abstractions.ITestOutputHelper) = - let formatter = Serilog.Formatting.Display.MessageTemplateTextFormatter("{Timestamp:HH:mm:ss.fff zzz} [{Level:u3}] {Message} {Properties}{NewLine}{Exception}", null); + let template = "{Timestamp:HH:mm:ss.fff zzz} [{Level:u3}] {Message} {Properties}{NewLine}{Exception}" + let formatter = Serilog.Formatting.Display.MessageTemplateTextFormatter(template, null); let writeSerilogEvent logEvent = use writer = new System.IO.StringWriter() formatter.Format(logEvent, writer) diff --git a/equinox-fc/Domain.Tests/LocationEpochTests.fs b/equinox-fc/Domain.Tests/LocationEpochTests.fs index 6901aa746..8683170e1 100644 --- a/equinox-fc/Domain.Tests/LocationEpochTests.fs +++ b/equinox-fc/Domain.Tests/LocationEpochTests.fs @@ -1,28 +1,30 @@ -module LocationEpochTests +module Fc.LocationEpochTests open FsCheck.Xunit open Location.Epoch open Swensen.Unquote -let interpret delta _balance = +let interpret transactionId delta _balance = match delta with | 0 -> (),[] - | delta -> (),[Events.Delta { value = delta }] + | delta when delta < 0 -> (), [Events.Removed { delta = -delta; transaction = transactionId }] + | delta -> (), [Events.Added { delta = delta; transaction = transactionId }] -let validateAndInterpret expectedBalance delta balance = +let validateAndInterpret transactionId expectedBalance delta balance = test <@ expectedBalance = balance @> - interpret delta balance + interpret transactionId delta balance -let verifyDeltaEvent delta events = - let dEvents = events |> List.filter (function Events.Delta _ -> true | _ -> false) - test <@ interpret delta (Unchecked.defaultof<_>) = ((),dEvents) @> +let verifyDeltaEvent transactionId delta events = + let dEvents = events |> List.filter (function Events.Added _ | Events.Removed _ -> true | _ -> false) + test <@ interpret transactionId delta (Unchecked.defaultof<_>) = ((),dEvents) @> -let [] properties carriedForward delta1 closeImmediately delta2 close = +let [] properties transactionId carriedForward delta1 closeImmediately delta2 close = (* Starting with an empty stream, we'll need to supply the balance carried forward, optionally we apply a delta and potentially close *) let initialShouldClose _state = closeImmediately - let res,events = sync (Some carriedForward) (validateAndInterpret carriedForward delta1) initialShouldClose Fold.initial + let res,events = + sync (Some carriedForward) (validateAndInterpret transactionId carriedForward delta1) initialShouldClose Fold.initial let cfEvents events = events |> List.filter (function Events.CarriedForward _ -> true | _ -> false) let closeEvents events = events |> List.filter (function Events.Closed -> true | _ -> false) let state1 = Fold.fold Fold.initial events @@ -33,12 +35,13 @@ let [] properties carriedForward delta1 closeImmediately delta2 close && expectedBalance = res.balance @> test <@ [Events.CarriedForward { initial = carriedForward }] = cfEvents events && (not expectImmediateClose || 1 = Seq.length (closeEvents events)) @> - verifyDeltaEvent delta1 events + verifyDeltaEvent transactionId delta1 events (* After initializing, validate we don't need to supply a carriedForward, and don't produce a CarriedForward event *) let shouldClose _state = close - let { isOpen = isOpen; result = worked; balance = bal },events = sync None (validateAndInterpret expectedBalance delta2) shouldClose state1 + let { isOpen = isOpen; result = worked; balance = bal },events = + sync None (validateAndInterpret transactionId expectedBalance delta2) shouldClose state1 let expectedBalance = if expectImmediateClose then expectedBalance else expectedBalance + delta2 test <@ [] = cfEvents events && (expectImmediateClose || not close || 1 = Seq.length (closeEvents events)) @> @@ -46,7 +49,7 @@ let [] properties carriedForward delta1 closeImmediately delta2 close && expectedBalance = bal @> if not expectImmediateClose then test <@ Option.isSome worked @> - verifyDeltaEvent delta2 events + verifyDeltaEvent transactionId delta2 events let [] ``codec can roundtrip`` event = let ee = Events.codec.Encode(None,event) diff --git a/equinox-fc/Domain.Tests/LocationSeriesTests.fs b/equinox-fc/Domain.Tests/LocationSeriesTests.fs index 4335d8128..d6fbde18b 100644 --- a/equinox-fc/Domain.Tests/LocationSeriesTests.fs +++ b/equinox-fc/Domain.Tests/LocationSeriesTests.fs @@ -1,4 +1,4 @@ -module LocationSeriesTests +module Fc.LocationSeriesTests open FsCheck.Xunit open FSharp.UMX @@ -6,7 +6,7 @@ open Swensen.Unquote open Location.Series let [] properties c1 c2 = - let events = interpretActivateEpoch c1 Fold.initial + let events = interpretAdvanceIngestionEpoch c1 Fold.initial let state1 = Fold.fold Fold.initial events let epoch0 = %0 match c1, events, state1 with @@ -20,7 +20,7 @@ let [] properties c1 c2 = | _, l, _ -> test <@ List.isEmpty l @> - let events = interpretActivateEpoch c2 state1 + let events = interpretAdvanceIngestionEpoch c2 state1 let state2 = Fold.fold state1 events match state1, c2, events, state2 with // Started events are not written for < 0 diff --git a/equinox-fc/Domain.Tests/LocationTests.fs b/equinox-fc/Domain.Tests/LocationTests.fs index 169834991..3f54c13a6 100644 --- a/equinox-fc/Domain.Tests/LocationTests.fs +++ b/equinox-fc/Domain.Tests/LocationTests.fs @@ -1,8 +1,8 @@ -module LocationTests +module Fc.LocationTests open FsCheck.Xunit open FSharp.UMX -open Location +open Fc.Location open Swensen.Unquote open System @@ -27,7 +27,7 @@ module Location = let epochs = Epoch.create (Epoch.resolve store) maxAttempts create (zeroBalance, shouldClose) (series, epochs) -let run (service : LocationService) (IdsAtLeastOne locations, deltas : _[]) = Async.RunSynchronously <| async { +let run (service : Location.Service) (IdsAtLeastOne locations, deltas : _[], transactionId) = Async.RunSynchronously <| async { let runId = mkId () // Need to make making state in store unique when replaying or shrinking let locations = locations |> Array.map (fun x -> % (sprintf "%O_%O" x runId)) @@ -38,7 +38,8 @@ let run (service : LocationService) (IdsAtLeastOne locations, deltas : _[]) = As let adjust delta (bal : Epoch.Fold.Balance) = let value = max -bal delta if value = 0 then 0, [] - else value, [Location.Epoch.Events.Delta { value = value }] + elif value < 0 then value, [Epoch.Events.Removed { delta = -value; transaction = transactionId }] + else value, [Epoch.Events.Added { delta = value; transaction = transactionId }] let! appliedDeltas = seq { for loc,x in updates -> async { let! _,eff = service.Execute(loc, adjust x) in return loc,eff } } |> Async.Parallel let expectedBalances = Seq.append (seq { for l in locations -> l, 0}) appliedDeltas |> Seq.groupBy fst |> Seq.map (fun (l,xs) -> l, xs |> Seq.sumBy snd) |> Set.ofSeq @@ -57,7 +58,7 @@ let [] ``MemoryStore properties`` maxEvents args = type Cosmos(testOutput) = - let context,cache = Cosmos.connect () + let context, cache = Cosmos.connect () let log = testOutput |> TestOutputAdapter |> createLogger do Serilog.Log.Logger <- log @@ -66,5 +67,5 @@ type Cosmos(testOutput) = let zeroBalance = 0 let maxEvents = max 1 maxEvents let shouldClose (state : Epoch.Fold.OpenState) = state.count > maxEvents - let service = Location.Cosmos.createService (zeroBalance, shouldClose) (context,cache,Int32.MaxValue) + let service = Location.Cosmos.createService (zeroBalance, shouldClose) (context, cache, 50) run service args \ No newline at end of file diff --git a/equinox-fc/Domain/Domain.fsproj b/equinox-fc/Domain/Domain.fsproj index 496064bdd..472c3527b 100644 --- a/equinox-fc/Domain/Domain.fsproj +++ b/equinox-fc/Domain/Domain.fsproj @@ -3,6 +3,7 @@ netstandard2.0 5 + true Fc.Domain @@ -14,7 +15,7 @@ - + diff --git a/equinox-fc/Domain/Infrastructure.fs b/equinox-fc/Domain/Infrastructure.fs index 0ef31b200..02863a4a3 100644 --- a/equinox-fc/Domain/Infrastructure.fs +++ b/equinox-fc/Domain/Infrastructure.fs @@ -6,14 +6,12 @@ type LocationId = string and [] locationId module LocationId = let parse (value : string) : LocationId = %value - let (|Parse|) = parse let toString (value : LocationId) : string = %value type LocationEpochId = int and [] locationEpochId module LocationEpochId = let parse (value : int) : LocationEpochId = %value - let (|Parse|) = parse let next (value : LocationEpochId) : LocationEpochId = % (%value + 1) let toString (value : LocationEpochId) : string = string %value @@ -21,14 +19,12 @@ type InventoryId = string and [] inventoryId module InventoryId = let parse (value : string) : InventoryId = %value - let (|Parse|) = parse let toString (value : InventoryId) : string = %value type InventoryEpochId = int and [] inventoryEpochId module InventoryEpochId = let parse (value : int) : InventoryEpochId = %value - let (|Parse|) = parse let next (value : InventoryEpochId) : InventoryEpochId = % (%value + 1) let toString (value : InventoryEpochId) : string = string %value @@ -36,5 +32,4 @@ type InventoryTransactionId = string and [] inventoryTransactionId module InventoryTransactionId = let parse (value : string) : InventoryTransactionId = %value - let (|Parse|) = parse - let toString (value : InventoryTransactionId) : string = %value \ No newline at end of file + let toString (value : InventoryTransactionId) : string = %value diff --git a/equinox-fc/Domain/Inventory.fs b/equinox-fc/Domain/Inventory.fs index d68cb3cfe..cf7a9301b 100644 --- a/equinox-fc/Domain/Inventory.fs +++ b/equinox-fc/Domain/Inventory.fs @@ -1,5 +1,6 @@ namespace Fc.Inventory +open Epoch open Equinox.Core // we use Equinox's AsyncCacheCell helper below open FSharp.UMX @@ -9,75 +10,75 @@ type internal IdsCache<'Id>() = member __.Add ids = for x in ids do all.[x] <- () member __.Contains id = all.ContainsKey id -/// Maintains active BatchId in a thread-safe manner while ingesting items into the chain of `batches` indexed by the `transmissions` -/// Prior to first add, reads `lookBack` batches to seed the cache, in order to minimize the number of duplicated pickTickets we transmit -type Ingester internal (fcId, series : Series.Service, epochs : Epoch.Service, lookBack, capacity) = +/// Maintains active Epoch Id in a thread-safe manner while ingesting items into the `series` of `epochs` +/// Prior to first add, reads `lookBack` epochs to seed the cache, in order to minimize the number of duplicated Ids we ingest +type Service internal (inventoryId, series : Series.Service, epochs : Epoch.Service, lookBack, capacity) = + + let log = Serilog.Log.ForContext() - let log = Serilog.Log.ForContext() // Maintains what we believe to be the currently open EpochId // Guaranteed to be set only after `previousIds.AwaitValue()` - let mutable activeId = Unchecked.defaultof<_> + let mutable activeEpochId = Unchecked.defaultof<_> // We want max one request in flight to establish the pre-existing Batches from which the tickets cache will be seeded - let previousBatches = AsyncCacheCell list> <| async { - let! startingId = transmissions.ReadIngestionBatchId(fcId) - activeId <- %startingId - let readBatch batchId = async { let! r = batches.IngestShipped(fcId,batchId,(fun _ -> 1),Seq.empty) in return r.batchContent } - return [ for b in (max 0 (%startingId - lookBack)) .. (%startingId - 1) -> AsyncCacheCell(readBatch %b) ] } + let previousEpochs = AsyncCacheCell> list> <| async { + let! startingId = series.ReadIngestionEpochId(inventoryId) + activeEpochId <- %startingId + let read epochId = async { let! r = epochs.TryIngest(inventoryId, epochId, (fun _ -> 1),Seq.empty) in return r.transactionIds } + return [ for epoch in (max 0 (%startingId - lookBack)) .. (%startingId - 1) -> AsyncCacheCell(read %epoch) ] } - // Tickets cache - used to maintain a list of tickets that have already been ingested in order to avoid db round-trips - let previousIds : AsyncCacheCell = AsyncCacheCell <| async { - let! batches = previousBatches.AwaitValue() + // TransactionIds cache - used to maintain a list of transactions that have already been ingested in order to avoid db round-trips + let previousIds : AsyncCacheCell> = AsyncCacheCell <| async { + let! batches = previousEpochs.AwaitValue() let! ids = seq { for x in batches -> x.AwaitValue() } |> Async.Parallel return IdsCache.Create(Seq.concat ids) } - let tryIngest items = async { - let! previousTickets = previousIds.AwaitValue() - let firstBatchId = %activeId + let tryIngest events = async { + let! previousIds = previousIds.AwaitValue() + let initialEpochId = %activeEpochId - let rec aux batchId totalIngestedTickets items = async { - let dup,fresh = items |> List.partition previousTickets.Contains + let rec aux epochId totalIngested items = async { + let SeqPartition f = Seq.toArray >> Array.partition f + let dup,fresh = items |> SeqPartition (Epoch.Events.chooseInventoryTransactionId >> Option.exists previousIds.Contains) let fullCount = List.length items - let dropping = fullCount - List.length fresh - if dropping <> 0 then log.Information("Ignoring {count}/{fullCount} duplicate ids: {ids} for {batchId}", dropping, fullCount, dup, batchId) - if List.isEmpty fresh then - return totalIngestedTickets + let dropping = fullCount - Array.length fresh + if dropping <> 0 then log.Information("Ignoring {count}/{fullCount} duplicate ids: {ids} for {epochId}", dropping, fullCount, dup, epochId) + if Array.isEmpty fresh then + return totalIngested else - let! res = batches.IngestShipped(fcId,batchId,capacity,fresh) - log.Information("Added {count} items to {fcId:l}/{batchId}", res.added, fcId, batchId) + let! res = epochs.TryIngest(inventoryId, epochId, capacity, fresh) + log.Information("Added {count} items to {inventoryId:l}/{epochId}", res.added, inventoryId, epochId) // The adding is potentially redundant; we don't care - previousTickets.Add res.batchContent - // Any writer noticing we've moved to a new batch shares the burden of marking it active - if not res.isClosed && activeId < %batchId then - log.Information("Marking {fcId:l}/{batchId} active", fcId, batchId) - do! transmissions.MarkIngestionBatchId(fcId,batchId) - System.Threading.Interlocked.CompareExchange(&activeId,%batchId,activeId) |> ignore - let totalIngestedTickets = totalIngestedTickets + res.added + previousIds.Add res.transactionIds + // Any writer noticing we've moved to a new epoch shares the burden of marking it active + if not res.isClosed && activeEpochId < %epochId then + log.Information("Marking {inventoryId:l}/{epochId} active", inventoryId, epochId) + do! series.AdvanceIngestionEpoch(inventoryId, epochId) + System.Threading.Interlocked.CompareExchange(&activeEpochId, %epochId,activeEpochId) |> ignore + let totalIngestedTickets = totalIngested + res.added match res.rejected with | [] -> return totalIngestedTickets - | rej -> return! aux (BatchId.next batchId) totalIngestedTickets rej } - return! aux firstBatchId 0 items + | rej -> return! aux (InventoryEpochId.next epochId) totalIngestedTickets rej } + return! aux initialEpochId 0 events } /// Upon startup, we initialize the PickTickets cache with recent batches; we want to kick that process off before our first ingest - member __.Initialize() = previousBatches.AwaitValue() |> Async.Ignore + member __.Initialize() = previousIds.AwaitValue() |> Async.Ignore - /// Feeds the items into the sequence of batches. Returns the number of items actually added [excluding duplicates] - member __.Ingest(items : PickTicketId list) : Async = tryIngest items + /// Feeds the events into the sequence of batches. Returns the number of items actually added [excluding duplicates] + member __.Ingest(events : Epoch.Events.Event list) : Async = tryIngest events -module PickTicketsIngester = +module internal Helpers = - let create fcId maxPickTicketsPerBatch lookBackLimit batchesResolve (epochOverride,transmissionsResolve) = - let remainingBatchCapacity (state: Financial.Batch.Fold.State) = - let l = state.ItemCount - max 0 (maxPickTicketsPerBatch-l) - let batches = Financial.Batch.createService batchesResolve - let transmissions = Financial.Transmissions.createService epochOverride transmissionsResolve - Ingester(fcId, batches, transmissions, lookBack=lookBackLimit, capacity=remainingBatchCapacity) + let create inventoryId maxTransactionsPerBatch lookBackLimit (series, epochs) = + let remainingBatchCapacity (state: Epoch.Fold.State) = + let currentLen = state.ids.Count + max 0 (maxTransactionsPerBatch - currentLen) + Service(inventoryId, series, epochs, lookBack = lookBackLimit, capacity = remainingBatchCapacity) - module Cosmos = +module Cosmos = - let create epochOverride fcId maxPickTicketsPerBatch lookBackLimit (context,cache) = - let batchesResolve = Financial.Batch.Cosmos.resolve (context,cache) - let transmissionsResolve = Financial.Transmissions.Cosmos.resolve (context,cache) - create fcId maxPickTicketsPerBatch lookBackLimit batchesResolve (epochOverride,transmissionsResolve) + let create inventoryId maxTransactionsPerBatch lookBackLimit (context, cache) = + let series = Series.Cosmos.createService (context, cache) + let epochs = Epoch.Cosmos.createService (context, cache) + Helpers.create inventoryId maxTransactionsPerBatch lookBackLimit (series, epochs) diff --git a/equinox-fc/Domain/InventoryEpoch.fs b/equinox-fc/Domain/InventoryEpoch.fs index 2c0877a15..f85691095 100644 --- a/equinox-fc/Domain/InventoryEpoch.fs +++ b/equinox-fc/Domain/InventoryEpoch.fs @@ -1,7 +1,5 @@ module Fc.Inventory.Epoch -open System - // NB - these types and the union case names reflect the actual storage formats and hence need to be versioned with care [] module Events = @@ -21,6 +19,11 @@ module Events = interface TypeShape.UnionContract.IUnionContract let codec = FsCodec.NewtonsoftJson.Codec.Create() + /// Used for deduplicating input events + let chooseInventoryTransactionId = function + | Adjusted { transactionId = id } | Transferred { transactionId = id } -> Some id + | Closed | Snapshotted _ -> None + module Fold = type State = { closed : bool; ids : Set } @@ -39,14 +42,16 @@ type Result = /// Count of items added to this epoch. May be less than requested due to removal of duplicates and/or rejected items added : int /// residual items that [are not duplicates and] were not accepted into this epoch - rejected : Events.Event list } + rejected : Events.Event list + /// identifiers for all items in this epoch + transactionIds : Set } -let decideSync capacity requested (state : Fold.State) : Result * Events.Event list = +let decideSync capacity events (state : Fold.State) : Result * Events.Event list = let isFresh = function | Events.Adjusted { transactionId = id } | Events.Transferred { transactionId = id } -> (not << state.ids.Contains) id | Events.Closed | Events.Snapshotted _ -> false - let news = requested |> Seq.filter isFresh |> List.ofSeq + let news = events |> Seq.filter isFresh |> List.ofSeq let closed,allowing,markClosed,residual = let newCount = List.length news if state.closed then @@ -61,18 +66,18 @@ let decideSync capacity requested (state : Fold.State) : Result * Events.Event l [ if allowing <> 0 then yield! news if markClosed then yield Events.Closed ] let state' = Fold.fold state events - { isClosed = closed; added = allowing; rejected = residual },events + { isClosed = closed; added = allowing; rejected = residual; transactionIds = state'.ids },events type Service internal (log, resolve, maxAttempts) = let resolve (Events.For streamId) = Equinox.Stream(log, resolve streamId, maxAttempts) /// Attempt ingestion of `events` into the cited Epoch. - /// - No `items` will be accepted if the Epoch is `closed` + /// - None will be accepted if the Epoch is `closed` /// - The `capacity` function will be passed a non-closed `state` in order to determine number of items that can be admitted prior to closing /// - If the computed capacity result is >= the number of items being submitted (which may be 0), the Epoch will be marked Closed /// NOTE the result may include rejected items (which the caller is expected to feed into a successor epoch) - member __.IngestShipped(inventoryId, epochId, capacity, events) : Async = + member __.TryIngest(inventoryId, epochId, capacity, events) : Async = let stream = resolve (inventoryId, epochId) stream.Transact(decideSync capacity events) @@ -81,8 +86,9 @@ let createService resolve = Service(Serilog.Log.ForContext(), resolve, module Cosmos = open Equinox.Cosmos + let accessStrategy = Equinox.Cosmos.AccessStrategy.Snapshot (Fold.isOrigin, Fold.snapshot) - let resolve (context,cache) = - let cacheStrategy = CachingStrategy.SlidingWindow (cache, TimeSpan.FromMinutes 20.) + let resolve (context, cache) = + let cacheStrategy = CachingStrategy.SlidingWindow (cache, System.TimeSpan.FromMinutes 20.) Resolver(context, Events.codec, Fold.fold, Fold.initial, cacheStrategy, accessStrategy).Resolve - let createService (context,cache) = createService (resolve (context,cache)) \ No newline at end of file + let createService (context, cache) = createService (resolve (context, cache)) diff --git a/equinox-fc/Domain/InventorySeries.fs b/equinox-fc/Domain/InventorySeries.fs index 20a25c7b5..d97d09177 100644 --- a/equinox-fc/Domain/InventorySeries.fs +++ b/equinox-fc/Domain/InventorySeries.fs @@ -21,7 +21,7 @@ module Fold = type State = { writing : InventoryEpochId; checkpoint : Events.Checkpoint option } - let initial = { writing = 0; checkpoint = None } + let initial = { writing = InventoryEpochId.parse 0; checkpoint = None } let evolve state = function | Events.Started e -> { state with writing = e.epoch } | Events.Checkpointed e -> { state with checkpoint = Some e } @@ -32,7 +32,7 @@ module Fold = let snapshot s = Events.Snapshotted { writing = s.writing; checkpoint = s.checkpoint } // TODO transmute to remove checkpoints a la Propulsion -let interpretMarkWriting epochId (state : Fold.State) = +let interpretAdvanceIngestionEpoch epochId (state : Fold.State) = if state.writing >= epochId then [] else [Events.Started { epoch = epochId }] @@ -44,9 +44,9 @@ type Service internal (log, resolve, maxAttempts) = let stream = resolve inventoryId stream.Query(fun s -> s.writing) - member __.UpdateEpoch(inventoryId, epochId) : Async = + member __.AdvanceIngestionEpoch(inventoryId, epochId) : Async = let stream = resolve inventoryId - stream.Transact(interpretMarkWriting epochId) + stream.Transact(interpretAdvanceIngestionEpoch epochId) // TODO checkpoint writing @@ -56,6 +56,7 @@ let createService resolve = module Cosmos = open Equinox.Cosmos + let resolve (context, cache) = let cacheStrategy = CachingStrategy.SlidingWindow (cache, System.TimeSpan.FromMinutes 20.) // For this stream, we uniformly use stale reads as: @@ -63,6 +64,6 @@ module Cosmos = // b) while there are competing writers [which might cause us to have to retry a Transact], this should be infrequent let opt = Equinox.ResolveOption.AllowStale let accessStrategy = AccessStrategy.Snapshot (Fold.isOrigin, Fold.snapshot) - fun id -> Resolver(context, Events.codec, Fold.fold, Fold.initial, cacheStrategy, accessStrategy).Resolve(id,opt) + fun id -> Resolver(context, Events.codec, Fold.fold, Fold.initial, cacheStrategy, accessStrategy).Resolve(id, opt) let createService (context, cache) = createService (resolve (context, cache)) diff --git a/equinox-fc/Domain/InventoryTransaction.fs b/equinox-fc/Domain/InventoryTransaction.fs index e12ceb256..1fd4c03c3 100644 --- a/equinox-fc/Domain/InventoryTransaction.fs +++ b/equinox-fc/Domain/InventoryTransaction.fs @@ -108,7 +108,7 @@ module Cosmos = open Equinox.Cosmos let accessStrategy = Equinox.Cosmos.AccessStrategy.Snapshot (Fold.isOrigin, Fold.snapshot) - let resolve (context,cache) = + let resolve (context, cache) = let cacheStrategy = CachingStrategy.SlidingWindow (cache, TimeSpan.FromMinutes 20.) Resolver(context, Events.codec, Fold.fold, Fold.initial, cacheStrategy, accessStrategy).Resolve - let createService (context,cache) = createService (resolve (context,cache)) \ No newline at end of file + let createService (context, cache) = createService (resolve (context,cache)) \ No newline at end of file diff --git a/equinox-fc/Domain/Location.fs b/equinox-fc/Domain/Location.fs index 562e1da70..8c8ac3b51 100644 --- a/equinox-fc/Domain/Location.fs +++ b/equinox-fc/Domain/Location.fs @@ -14,7 +14,7 @@ type Service internal (zeroBalance, shouldClose, series : Series.Service, epochs match! epochs.Sync(locationId, epochId, balanceToCarryForward, decide, shouldClose) with | { balance = bal; result = Some res; isOpen = true } -> if originEpochId <> epochId then - do! series.ActivateEpoch(locationId, epochId) + do! series.AdvanceIngestionEpoch(locationId, epochId) return bal, res | { balance = bal; result = Some res } -> let successorEpochId = LocationEpochId.next epochId @@ -25,7 +25,7 @@ type Service internal (zeroBalance, shouldClose, series : Series.Service, epochs aux member __.Execute(locationId, decide) = async { - let! activeEpoch = series.ReadIngestionEpoch locationId + let! activeEpoch = series.TryReadIngestionEpoch locationId let originEpochId, epochId, balanceCarriedForward = match activeEpoch with | None -> LocationEpochId.parse -1, LocationEpochId.parse 0, Some zeroBalance @@ -43,4 +43,4 @@ module Cosmos = let createService (zeroBalance, shouldClose) (context, cache, maxAttempts) = let series = Series.Cosmos.createService (context, cache, maxAttempts) let epochs = Epoch.Cosmos.createService (context, cache, maxAttempts) - create (zeroBalance, shouldClose) (series, epochs) \ No newline at end of file + create (zeroBalance, shouldClose) (series, epochs) diff --git a/equinox-fc/Domain/LocationEpoch.fs b/equinox-fc/Domain/LocationEpoch.fs index 9c92b2bc5..15508e3b7 100644 --- a/equinox-fc/Domain/LocationEpoch.fs +++ b/equinox-fc/Domain/LocationEpoch.fs @@ -47,7 +47,8 @@ type private Accumulator() = type Result<'t> = { balance : Fold.Balance; result : 't option; isOpen : bool } -let sync (balanceCarriedForward : Fold.Balance option) (decide : (Fold.Balance -> 't*Events.Event list)) shouldClose state : Result<'t>*Events.Event list = +let sync (balanceCarriedForward : Fold.Balance option) (decide : Fold.Balance -> 't*Events.Event list) shouldClose state : Result<'t>*Events.Event list = + let acc = Accumulator() // We require a CarriedForward event at the start of any Epoch's event stream let (), state = @@ -85,8 +86,9 @@ let create resolve maxAttempts = Service(Serilog.Log.ForContext(), reso module Cosmos = open Equinox.Cosmos - let resolve (context,cache) = + + let resolve (context, cache) = let cacheStrategy = CachingStrategy.SlidingWindow (cache, System.TimeSpan.FromMinutes 20.) Resolver(context, Events.codec, Fold.fold, Fold.initial, cacheStrategy, AccessStrategy.Unoptimized).Resolve - let createService (context,cache,maxAttempts) = - create (resolve (context,cache)) maxAttempts \ No newline at end of file + let createService (context, cache, maxAttempts) = + create (resolve (context, cache)) maxAttempts diff --git a/equinox-fc/Domain/LocationSeries.fs b/equinox-fc/Domain/LocationSeries.fs index f69801b6a..3221509ea 100644 --- a/equinox-fc/Domain/LocationSeries.fs +++ b/equinox-fc/Domain/LocationSeries.fs @@ -16,34 +16,37 @@ module Events = module Fold = type State = LocationEpochId option - let initial = None + let initial : State = None let private evolve _state = function | Events.Started e -> Some e.epochId let fold = Seq.fold evolve -let interpretActivateEpoch epochId (state : Fold.State) = +let interpretAdvanceIngestionEpoch (epochId : LocationEpochId) (state : Fold.State) = + if epochId < LocationEpochId.parse 0 then [] else + [if state |> Option.forall (fun s -> s < epochId) then yield Events.Started { epochId = epochId }] type Service internal (log, resolve, maxAttempts) = let resolve (Events.For id) = Equinox.Stream(log, resolve id, maxAttempts) - member __.ReadIngestionEpoch(locationId) : Async = + member __.TryReadIngestionEpoch(locationId) : Async = let stream = resolve locationId stream.Query id - member __.ActivateEpoch(locationId, epochId) : Async = + member __.AdvanceIngestionEpoch(locationId, epochId) : Async = let stream = resolve locationId - stream.Transact(interpretActivateEpoch epochId) + stream.Transact(interpretAdvanceIngestionEpoch epochId) let create resolve maxAttempts = Service(Serilog.Log.ForContext(), resolve, maxAttempts) module Cosmos = open Equinox.Cosmos - let resolve (context,cache) = + + let resolve (context, cache) = let cacheStrategy = CachingStrategy.SlidingWindow (cache, System.TimeSpan.FromMinutes 20.) let opt = Equinox.ResolveOption.AllowStale fun id -> Resolver(context, Events.codec, Fold.fold, Fold.initial, cacheStrategy, AccessStrategy.LatestKnownEvent).Resolve(id,opt) let createService (context, cache, maxAttempts) = - create (resolve (context,cache)) maxAttempts \ No newline at end of file + create (resolve (context, cache)) maxAttempts From 20605ee5dfd6333cf2724818f61323a49711b0fc Mon Sep 17 00:00:00 2001 From: Ruben Bartelink Date: Thu, 13 Feb 2020 13:33:45 +0000 Subject: [PATCH 14/51] Yes, comments --- equinox-fc/Domain.Tests/Domain.Tests.fsproj | 2 +- equinox-fc/Domain.Tests/Infrastructure.fs | 2 +- equinox-fc/Domain.Tests/LocationEpochTests.fs | 2 +- equinox-fc/Domain.Tests/LocationSeriesTests.fs | 2 +- equinox-fc/Domain.Tests/LocationTests.fs | 2 +- equinox-fc/Domain/InventoryEpoch.fs | 3 +++ equinox-fc/Domain/InventorySeries.fs | 4 ++++ equinox-fc/Domain/Location.fs | 2 +- equinox-fc/Domain/LocationEpoch.fs | 3 +++ equinox-fc/Domain/LocationSeries.fs | 1 + 10 files changed, 17 insertions(+), 6 deletions(-) diff --git a/equinox-fc/Domain.Tests/Domain.Tests.fsproj b/equinox-fc/Domain.Tests/Domain.Tests.fsproj index 078801f4e..40feab0a9 100644 --- a/equinox-fc/Domain.Tests/Domain.Tests.fsproj +++ b/equinox-fc/Domain.Tests/Domain.Tests.fsproj @@ -1,7 +1,7 @@ - netcoreapp3.1 + netcoreapp2.1 5 Fc.Domain.Tests false diff --git a/equinox-fc/Domain.Tests/Infrastructure.fs b/equinox-fc/Domain.Tests/Infrastructure.fs index 3f0d4f26e..0711ee67b 100644 --- a/equinox-fc/Domain.Tests/Infrastructure.fs +++ b/equinox-fc/Domain.Tests/Infrastructure.fs @@ -48,4 +48,4 @@ let createLogger sink = // .MinimumLevel.Debug() .Destructure.FSharpTypes() .WriteTo.Sink(sink) - .CreateLogger() \ No newline at end of file + .CreateLogger() diff --git a/equinox-fc/Domain.Tests/LocationEpochTests.fs b/equinox-fc/Domain.Tests/LocationEpochTests.fs index 8683170e1..3f1379484 100644 --- a/equinox-fc/Domain.Tests/LocationEpochTests.fs +++ b/equinox-fc/Domain.Tests/LocationEpochTests.fs @@ -54,4 +54,4 @@ let [] properties transactionId carriedForward delta1 closeImmediately let [] ``codec can roundtrip`` event = let ee = Events.codec.Encode(None,event) let ie = FsCodec.Core.TimelineEvent.Create(0L, ee.EventType, ee.Data) - test <@ Some event = Events.codec.TryDecode ie @> \ No newline at end of file + test <@ Some event = Events.codec.TryDecode ie @> diff --git a/equinox-fc/Domain.Tests/LocationSeriesTests.fs b/equinox-fc/Domain.Tests/LocationSeriesTests.fs index d6fbde18b..b972071d0 100644 --- a/equinox-fc/Domain.Tests/LocationSeriesTests.fs +++ b/equinox-fc/Domain.Tests/LocationSeriesTests.fs @@ -41,4 +41,4 @@ let [] properties c1 c2 = let [] ``codec can roundtrip`` event = let ee = Events.codec.Encode(None,event) let ie = FsCodec.Core.TimelineEvent.Create(0L, ee.EventType, ee.Data) - test <@ Some event = Events.codec.TryDecode ie @> \ No newline at end of file + test <@ Some event = Events.codec.TryDecode ie @> diff --git a/equinox-fc/Domain.Tests/LocationTests.fs b/equinox-fc/Domain.Tests/LocationTests.fs index 3f54c13a6..ab4a8660a 100644 --- a/equinox-fc/Domain.Tests/LocationTests.fs +++ b/equinox-fc/Domain.Tests/LocationTests.fs @@ -68,4 +68,4 @@ type Cosmos(testOutput) = let maxEvents = max 1 maxEvents let shouldClose (state : Epoch.Fold.OpenState) = state.count > maxEvents let service = Location.Cosmos.createService (zeroBalance, shouldClose) (context, cache, 50) - run service args \ No newline at end of file + run service args diff --git a/equinox-fc/Domain/InventoryEpoch.fs b/equinox-fc/Domain/InventoryEpoch.fs index f85691095..d8fafbe42 100644 --- a/equinox-fc/Domain/InventoryEpoch.fs +++ b/equinox-fc/Domain/InventoryEpoch.fs @@ -1,3 +1,6 @@ +/// Manages the ingestion (and deduplication based on a TransactionId) of events reflecting transfers or stock adjustments +/// that have been effected across a given set of Inventory +/// See Inventory.Service for surface level API which manages the ingestion, including transitioning to a new Epoch when an epoch reaches 'full' state module Fc.Inventory.Epoch // NB - these types and the union case names reflect the actual storage formats and hence need to be versioned with care diff --git a/equinox-fc/Domain/InventorySeries.fs b/equinox-fc/Domain/InventorySeries.fs index d97d09177..60e9d7456 100644 --- a/equinox-fc/Domain/InventorySeries.fs +++ b/equinox-fc/Domain/InventorySeries.fs @@ -1,3 +1,7 @@ +/// Manages a) the ingestion epoch id b) the current checkpointed read position for a long-running Inventory Series +/// See InventoryEpoch for the logic managing the actual events logged within a given epoch +/// See Inventory.Service for the surface API which manages the writing + module Fc.Inventory.Series // NB - these types and the union case names reflect the actual storage formats and hence need to be versioned with care diff --git a/equinox-fc/Domain/Location.fs b/equinox-fc/Domain/Location.fs index 8c8ac3b51..7527bde77 100644 --- a/equinox-fc/Domain/Location.fs +++ b/equinox-fc/Domain/Location.fs @@ -5,7 +5,7 @@ type Wip<'R> = | Pending of decide : (Epoch.Fold.Balance -> 'R * Epoch.Events.Event list) | Complete of 'R -/// Manages a Series of Epochs, with a running total being carried forward to the next Epoch when it's Closed +/// Manages Reads and Writes for a Series of Epochs, with a running total being carried forward to the next Epoch when it's marked Closed type Service internal (zeroBalance, shouldClose, series : Series.Service, epochs : Epoch.Service) = let rec execute locationId originEpochId = diff --git a/equinox-fc/Domain/LocationEpoch.fs b/equinox-fc/Domain/LocationEpoch.fs index 15508e3b7..24e9d3b5d 100644 --- a/equinox-fc/Domain/LocationEpoch.fs +++ b/equinox-fc/Domain/LocationEpoch.fs @@ -1,3 +1,6 @@ +/// Manages Stock adjustments and deltas for a given Location +/// Provides for controlled opening and closing of an epoch, carrying forward incoming balances when a given Epoch reaches a 'full' state +/// See Location.Service for the logic that allows competing readers/writers to co-operate in bringing this about module Fc.Location.Epoch // NOTE - these types and the union case names reflect the actual storage formats and hence need to be versioned with care diff --git a/equinox-fc/Domain/LocationSeries.fs b/equinox-fc/Domain/LocationSeries.fs index 3221509ea..db922e71b 100644 --- a/equinox-fc/Domain/LocationSeries.fs +++ b/equinox-fc/Domain/LocationSeries.fs @@ -1,3 +1,4 @@ +/// Manages the active epoch for a given Location module Fc.Location.Series // NOTE - these types and the union case names reflect the actual storage formats and hence need to be versioned with care From ff7b94009a1756714234d63d1eeca2ae9cc2e3e1 Mon Sep 17 00:00:00 2001 From: Ruben Bartelink Date: Thu, 13 Feb 2020 16:48:13 +0000 Subject: [PATCH 15/51] tmp --- equinox-fc/Domain/Domain.fsproj | 2 +- equinox-fc/Domain/InventoryTransaction.fs | 26 +++++++++++++---------- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/equinox-fc/Domain/Domain.fsproj b/equinox-fc/Domain/Domain.fsproj index 472c3527b..1ac6eac73 100644 --- a/equinox-fc/Domain/Domain.fsproj +++ b/equinox-fc/Domain/Domain.fsproj @@ -15,7 +15,7 @@ - + diff --git a/equinox-fc/Domain/InventoryTransaction.fs b/equinox-fc/Domain/InventoryTransaction.fs index 1fd4c03c3..0c2bc1482 100644 --- a/equinox-fc/Domain/InventoryTransaction.fs +++ b/equinox-fc/Domain/InventoryTransaction.fs @@ -1,3 +1,12 @@ +/// Process Manager used to: +/// - Coordinate competing attempts to transfer quantities from stock; if balance is 3 one of contesting requests to remove 2 or 3 items must reach `Failed` +/// - maintain rolling balance of stock levels per Location +/// - while recording any transfers or adjustment in an overall Inventory record +/// The Process is driven by two actors: +/// 1) The 'happy path', where a given actor is executing the known steps of the command flow +/// In the normal case, such an actor will bring the flow to a terminal state (Completed or Failed) +/// 2) A watchdog-projector, which reacts to observed events in this Category by stepping in to complete in-flight requests that have stalled +/// This represents the case where a 'happy path' actor died, or experienced another impediment on the path. module Fc.InventoryTransaction open System @@ -6,14 +15,13 @@ open System [] module Events = - let [] CategoryId = "Transfer" - let (|For|) (inventoryId, transactionId) = - FsCodec.StreamName.compose CategoryId [InventoryId.toString inventoryId; InventoryTransactionId.toString transactionId] + let [] CategoryId = "InventoryTransfer" + let (|For|) (inventoryId, transactionId) = FsCodec.StreamName.create CategoryId (InventoryTransactionId.toString transactionId) + type AdjustmentRequested = { location : LocationId; quantity : int } type TransferRequested = { source : LocationId; destination : LocationId; quantity : int } type Removed = { balance : int } type Added = { balance : int } - type AdjustmentRequested = { location : LocationId; quantity : int } type Event = | AdjustmentRequested of AdjustmentRequested @@ -59,13 +67,9 @@ module Fold = | Events.Completed -> Completed let fold : State -> Events.Event seq -> State = Seq.fold evolve -type Result = - { /// Indicates whether this epoch is closed (either previously or as a side-effect this time) - isClosed : bool - /// Count of items added to this epoch. May be less than requested due to removal of duplicates and/or rejected items - added : int - /// residual items that [are not duplicates and] were not accepted into this epoch - rejected : Events.Event list } +type Command = + | Adjust of Events.AdjustmentRequested + | Transfer of Events.TransferRequested let decideSync capacity requested (state : Fold.State) : Result * Events.Event list = let isFresh = function From 4b6781dffbb9076d49665798868ac495935a1ede Mon Sep 17 00:00:00 2001 From: Ruben Bartelink Date: Thu, 13 Feb 2020 18:45:29 +0000 Subject: [PATCH 16/51] WIP code snipped --- equinox-fc/Domain/InventoryTransaction.fs | 46 ----------------------- 1 file changed, 46 deletions(-) diff --git a/equinox-fc/Domain/InventoryTransaction.fs b/equinox-fc/Domain/InventoryTransaction.fs index 0c2bc1482..25ac55139 100644 --- a/equinox-fc/Domain/InventoryTransaction.fs +++ b/equinox-fc/Domain/InventoryTransaction.fs @@ -70,49 +70,3 @@ module Fold = type Command = | Adjust of Events.AdjustmentRequested | Transfer of Events.TransferRequested - -let decideSync capacity requested (state : Fold.State) : Result * Events.Event list = - let isFresh = function - | Events.Adjusted { transactionId = id } - | Events.Transferred { transactionId = id } -> (not << state.ids.Contains) id - | Events.Closed | Events.Snapshotted _ -> false - let news = requested |> Seq.filter isFresh |> List.ofSeq - let closed,allowing,markClosed,residual = - let newCount = List.length news - if state.closed then - true,0,false,news - else - let capacityNow = capacity state - let accepting = min capacityNow newCount - let closing = accepting = capacityNow - let residual = List.skip accepting news - closing,accepting,closing,residual - let events = - [ if allowing <> 0 then yield! news - if markClosed then yield Events.Closed ] - let state' = Fold.fold state events - { isClosed = closed; added = allowing; rejected = residual },events - -type Service internal (log, resolve, maxAttempts) = - - let resolve (Events.For streamId) = Equinox.Stream(log, resolve streamId, maxAttempts) - - /// Attempt ingestion of `events` into the cited Epoch. - /// - No `items` will be accepted if the Epoch is `closed` - /// - The `capacity` function will be passed a non-closed `state` in order to determine number of items that can be admitted prior to closing - /// - If the computed capacity result is >= the number of items being submitted (which may be 0), the Epoch will be marked Closed - /// NOTE the result may include rejected items (which the caller is expected to feed into a successor epoch) - member __.IngestShipped(inventoryId, epochId, capacity, events) : Async = - let stream = resolve (inventoryId, epochId) - stream.Transact(decideSync capacity events) - -let createService resolve = Service(Serilog.Log.ForContext(), resolve, maxAttempts = 2) - -module Cosmos = - - open Equinox.Cosmos - let accessStrategy = Equinox.Cosmos.AccessStrategy.Snapshot (Fold.isOrigin, Fold.snapshot) - let resolve (context, cache) = - let cacheStrategy = CachingStrategy.SlidingWindow (cache, TimeSpan.FromMinutes 20.) - Resolver(context, Events.codec, Fold.fold, Fold.initial, cacheStrategy, accessStrategy).Resolve - let createService (context, cache) = createService (resolve (context,cache)) \ No newline at end of file From 1c47ee1feb57e680ea113252b9429fa54ac9ce6f Mon Sep 17 00:00:00 2001 From: Ruben Bartelink Date: Thu, 13 Feb 2020 19:32:20 +0000 Subject: [PATCH 17/51] Add missing README.md --- dotnet-templates.sln | 1 + equinox-fc/Domain/InventoryTransaction.fs | 8 ++++---- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/dotnet-templates.sln b/dotnet-templates.sln index 780cc9ce9..18f262e39 100644 --- a/dotnet-templates.sln +++ b/dotnet-templates.sln @@ -100,6 +100,7 @@ EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "equinox-fc", "equinox-fc", "{4946576F-1558-49ED-A272-6F4D92FB0031}" ProjectSection(SolutionItems) = preProject equinox-fc\.template.config\template.json = equinox-fc\.template.config\template.json + equinox-fc\README.md = equinox-fc\README.md EndProjectSection EndProject Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Domain", "equinox-fc\Domain\Domain.fsproj", "{B3CFC965-6AB9-47E8-AA47-548A8D8A2E2C}" diff --git a/equinox-fc/Domain/InventoryTransaction.fs b/equinox-fc/Domain/InventoryTransaction.fs index 25ac55139..6ace7369b 100644 --- a/equinox-fc/Domain/InventoryTransaction.fs +++ b/equinox-fc/Domain/InventoryTransaction.fs @@ -36,17 +36,17 @@ module Events = module Fold = - type Removed = { request : Events.TransferRequested; removed : Events.Removed } - type Added = { request : Events.TransferRequested; removed : Events.Removed; added : Events.Added } type State = | Initial | Adjusting of Events.AdjustmentRequested | Adjusted of Events.AdjustmentRequested | Transferring of Events.TransferRequested - | Failed + | Rejected | Adding of Removed | Added of Added | Completed + and Removed = { request : Events.TransferRequested; removed : Events.Removed } + and Added = { request : Events.TransferRequested; removed : Events.Removed; added : Events.Added } let initial = Initial let evolve state = function | Events.AdjustmentRequested e -> Adjusting e @@ -55,7 +55,7 @@ module Fold = | Adjusting s -> Adjusted s | x -> failwithf "Unexpected %A when %A " ee state | Events.TransferRequested e -> Transferring e - | Events.Failed -> Failed + | Events.Failed -> Rejected | Events.Removed e as ee -> match state with | Transferring s -> Adding { request = s; removed = e } From 9229fde26cc45db6f8673fde49a73a0320edbe8c Mon Sep 17 00:00:00 2001 From: Ruben Bartelink Date: Fri, 14 Feb 2020 03:25:16 +0000 Subject: [PATCH 18/51] Tidy accessStrategy --- equinox-fc/Domain/InventorySeries.fs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/equinox-fc/Domain/InventorySeries.fs b/equinox-fc/Domain/InventorySeries.fs index 60e9d7456..33b7e2049 100644 --- a/equinox-fc/Domain/InventorySeries.fs +++ b/equinox-fc/Domain/InventorySeries.fs @@ -61,13 +61,13 @@ module Cosmos = open Equinox.Cosmos + let accessStrategy = AccessStrategy.Snapshot (Fold.isOrigin, Fold.snapshot) let resolve (context, cache) = let cacheStrategy = CachingStrategy.SlidingWindow (cache, System.TimeSpan.FromMinutes 20.) // For this stream, we uniformly use stale reads as: // a) we don't require any information from competing writers // b) while there are competing writers [which might cause us to have to retry a Transact], this should be infrequent let opt = Equinox.ResolveOption.AllowStale - let accessStrategy = AccessStrategy.Snapshot (Fold.isOrigin, Fold.snapshot) fun id -> Resolver(context, Events.codec, Fold.fold, Fold.initial, cacheStrategy, accessStrategy).Resolve(id, opt) let createService (context, cache) = createService (resolve (context, cache)) From 0895776a262964423db10308a5d21eb87884a0e7 Mon Sep 17 00:00:00 2001 From: Ruben Bartelink Date: Fri, 14 Feb 2020 15:06:38 +0000 Subject: [PATCH 19/51] Formatting --- equinox-fc/Domain.Tests/Infrastructure.fs | 2 +- equinox-fc/Domain.Tests/LocationEpochTests.fs | 2 +- equinox-fc/Domain.Tests/LocationSeriesTests.fs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/equinox-fc/Domain.Tests/Infrastructure.fs b/equinox-fc/Domain.Tests/Infrastructure.fs index 0711ee67b..1c6c0b920 100644 --- a/equinox-fc/Domain.Tests/Infrastructure.fs +++ b/equinox-fc/Domain.Tests/Infrastructure.fs @@ -26,7 +26,7 @@ module Cosmos = let context = Context(connection, d, c) let cache = Equinox.Cache (appName, 10) context, cache - | s,d,c -> + | s, d, c -> failwithf "Connection, Database and Container EQUINOX_COSMOS_* Environment variables are required (%b,%b,%b)" (Option.isSome s) (Option.isSome d) (Option.isSome c) diff --git a/equinox-fc/Domain.Tests/LocationEpochTests.fs b/equinox-fc/Domain.Tests/LocationEpochTests.fs index 3f1379484..49c09a264 100644 --- a/equinox-fc/Domain.Tests/LocationEpochTests.fs +++ b/equinox-fc/Domain.Tests/LocationEpochTests.fs @@ -52,6 +52,6 @@ let [] properties transactionId carriedForward delta1 closeImmediately verifyDeltaEvent transactionId delta2 events let [] ``codec can roundtrip`` event = - let ee = Events.codec.Encode(None,event) + let ee = Events.codec.Encode(None, event) let ie = FsCodec.Core.TimelineEvent.Create(0L, ee.EventType, ee.Data) test <@ Some event = Events.codec.TryDecode ie @> diff --git a/equinox-fc/Domain.Tests/LocationSeriesTests.fs b/equinox-fc/Domain.Tests/LocationSeriesTests.fs index b972071d0..6c7b33ae7 100644 --- a/equinox-fc/Domain.Tests/LocationSeriesTests.fs +++ b/equinox-fc/Domain.Tests/LocationSeriesTests.fs @@ -39,6 +39,6 @@ let [] properties c1 c2 = test <@ List.isEmpty l @> let [] ``codec can roundtrip`` event = - let ee = Events.codec.Encode(None,event) + let ee = Events.codec.Encode(None, event) let ie = FsCodec.Core.TimelineEvent.Create(0L, ee.EventType, ee.Data) test <@ Some event = Events.codec.TryDecode ie @> From 2daf7f09179e861f0130ad9c27b65a3fb9522d28 Mon Sep 17 00:00:00 2001 From: Ruben Bartelink Date: Fri, 14 Feb 2020 15:20:15 +0000 Subject: [PATCH 20/51] InventoryTransaction wip --- equinox-fc/Domain/InventoryTransaction.fs | 121 +++++++++++++++++----- 1 file changed, 94 insertions(+), 27 deletions(-) diff --git a/equinox-fc/Domain/InventoryTransaction.fs b/equinox-fc/Domain/InventoryTransaction.fs index 6ace7369b..1799abd38 100644 --- a/equinox-fc/Domain/InventoryTransaction.fs +++ b/equinox-fc/Domain/InventoryTransaction.fs @@ -9,13 +9,11 @@ /// This represents the case where a 'happy path' actor died, or experienced another impediment on the path. module Fc.InventoryTransaction -open System - // NB - these types and the union case names reflect the actual storage formats and hence need to be versioned with care [] module Events = - let [] CategoryId = "InventoryTransfer" + let [] CategoryId = "InventoryTransaction" let (|For|) (inventoryId, transactionId) = FsCodec.StreamName.create CategoryId (InventoryTransactionId.toString transactionId) type AdjustmentRequested = { location : LocationId; quantity : int } @@ -24,13 +22,18 @@ module Events = type Added = { balance : int } type Event = + (* Stock Adjustment Flow *) | AdjustmentRequested of AdjustmentRequested | Adjusted + + (* Stock Transfer Flow *) | TransferRequested of TransferRequested - | Failed + | Failed // terminal | Removed of Removed | Added of Added - | Completed + + (* Successful completion *) + | Completed // terminal interface TypeShape.UnionContract.IUnionContract let codec = FsCodec.NewtonsoftJson.Codec.Create() @@ -38,35 +41,99 @@ module Fold = type State = | Initial - | Adjusting of Events.AdjustmentRequested - | Adjusted of Events.AdjustmentRequested - | Transferring of Events.TransferRequested - | Rejected + | Running of RunningState + | Completed of TerminalState + and RunningState = + | Adjust of Events.AdjustmentRequested + | Transfer of TransferState + and TransferState = + | Requested of Events.TransferRequested + | Failed of Events.TransferRequested | Adding of Removed | Added of Added - | Completed + and TerminalState = + | Adjusted of Events.AdjustmentRequested + | Transferred of Added + | TransferFailed of Events.TransferRequested + and AdjustState = Events.AdjustmentRequested and Removed = { request : Events.TransferRequested; removed : Events.Removed } and Added = { request : Events.TransferRequested; removed : Events.Removed; added : Events.Added } let initial = Initial - let evolve state = function - | Events.AdjustmentRequested e -> Adjusting e - | Events.Adjusted as ee -> - match state with - | Adjusting s -> Adjusted s - | x -> failwithf "Unexpected %A when %A " ee state - | Events.TransferRequested e -> Transferring e - | Events.Failed -> Rejected - | Events.Removed e as ee -> - match state with - | Transferring s -> Adding { request = s; removed = e } - | x -> failwithf "Unexpected %A when %A " ee state - | Events.Added e as ee -> - match state with - | Adding s -> Added { request = s.request; removed = s.removed; added = e } - | x -> failwithf "Unexpected %A when %A " ee state - | Events.Completed -> Completed + let evolve state event = + match state, event with + (* Adjustment Process *) + | Initial, Events.AdjustmentRequested e -> + Running (Adjust e) + | Running (Adjust s), Events.Adjusted -> + Completed (Adjusted s) + + (* Transfer Process *) + | Initial, Events.TransferRequested e -> + Running (Transfer (Requested e)) + + | Running (Transfer (Requested s)), Events.Failed -> + Completed (TransferFailed s) + + | Running (Transfer (Requested s)), Events.Removed e as ee -> + Running (Transfer (Adding { request = s; removed = e })) + | Running (Transfer (Adding s)), Events.Added e as ee -> + Running (Transfer (Added { request = s.request; removed = s.removed; added = e })) + | Running (Transfer (Added s)), Events.Completed as ee -> + Completed (Transferred s) + + (* Any disallowed state changes represent gaps in the model, so we fail fast *) + | state, event -> failwithf "Unexpected %A when %A" event state + let fold : State -> Events.Event seq -> State = Seq.fold evolve type Command = | Adjust of Events.AdjustmentRequested | Transfer of Events.TransferRequested + +type Result = { complete : bool; } + +/// Holds events accumulated from a series of decisions while also evolving the presented `state` to reflect the pended events +type private Accumulator() = + let acc = ResizeArray() + member __.Ingest(state) : 'res * Events.Event list -> 'res * Fold.State = function + | res, [] -> res, state + | res, [e] -> acc.Add e; res, Fold.evolve state e + | res, xs -> acc.AddRange xs; res, Fold.fold state (Seq.ofList xs) + member __.Accumulated = List.ofSeq acc + +type Update = + | AdjustmentCompleted + +let decide command updates (state : Fold.State) : Result * Events.Event list = + + let acc = Accumulator() + let started, state = + acc.Ingest state <| + match state, command with + | Fold.Initial, Adjust e -> true, [ Events.AdjustmentRequested e ] + | Fold.Initial, Transfer e -> true, [ Events.TransferRequested e ] + | _ -> false, [] + { complete = false }, acc.Accumulated + +type Service internal (log, resolve, maxAttempts) = + + let resolve (Events.For streamId) = Equinox.Stream(log, resolve streamId, maxAttempts) + + member __.IngestShipped(inventoryId, transactionId, command, updates) : Async = + let stream = resolve (inventoryId, transactionId) + stream.Transact(decide command updates) + +let createService resolve = Service(Serilog.Log.ForContext(), resolve, maxAttempts = 2) + +module Cosmos = + + open Equinox.Cosmos + + // in the happy path case, the event stream will typically be short, and the state cached, so snapshotting is less critical + let accessStrategy = Equinox.Cosmos.AccessStrategy.Unoptimized + // ... and there will generally be a single actor touching it at a given time, so we don't need to do a load (which would be more expensive than normal given the `accessStrategy`) before we sync + let opt = Equinox.AllowStale + let resolve (context, cache) = + let cacheStrategy = CachingStrategy.SlidingWindow (cache, System.TimeSpan.FromMinutes 20.) + fun id -> Resolver(context, Events.codec, Fold.fold, Fold.initial, cacheStrategy, accessStrategy).Resolve(id, opt) + let createService (context, cache) = createService (resolve (context, cache)) \ No newline at end of file From e6b3e86adc319b0124914830dfb98f7d6955a234 Mon Sep 17 00:00:00 2001 From: Ruben Bartelink Date: Fri, 14 Feb 2020 23:59:58 +0000 Subject: [PATCH 21/51] Handle FsCodec _ restriction --- equinox-fc/Domain.Tests/LocationTests.fs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/equinox-fc/Domain.Tests/LocationTests.fs b/equinox-fc/Domain.Tests/LocationTests.fs index ab4a8660a..bf21327a2 100644 --- a/equinox-fc/Domain.Tests/LocationTests.fs +++ b/equinox-fc/Domain.Tests/LocationTests.fs @@ -29,7 +29,7 @@ module Location = let run (service : Location.Service) (IdsAtLeastOne locations, deltas : _[], transactionId) = Async.RunSynchronously <| async { let runId = mkId () // Need to make making state in store unique when replaying or shrinking - let locations = locations |> Array.map (fun x -> % (sprintf "%O_%O" x runId)) + let locations = locations |> Array.map (fun x -> % (sprintf "%O/%O" x runId)) let updates = deltas |> Seq.mapi (fun i x -> locations.[i % locations.Length], x) |> Seq.cache From ca1d9575487fe53a968e3715a686118ac027f031 Mon Sep 17 00:00:00 2001 From: Ruben Bartelink Date: Sat, 15 Feb 2020 00:00:35 +0000 Subject: [PATCH 22/51] Remove inventoryId from Trans SN --- equinox-fc/Domain.Tests/LocationTests.fs | 2 +- equinox-fc/Domain/InventoryTransaction.fs | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/equinox-fc/Domain.Tests/LocationTests.fs b/equinox-fc/Domain.Tests/LocationTests.fs index bf21327a2..03a3972bb 100644 --- a/equinox-fc/Domain.Tests/LocationTests.fs +++ b/equinox-fc/Domain.Tests/LocationTests.fs @@ -1,8 +1,8 @@ module Fc.LocationTests +open Fc.Location open FsCheck.Xunit open FSharp.UMX -open Fc.Location open Swensen.Unquote open System diff --git a/equinox-fc/Domain/InventoryTransaction.fs b/equinox-fc/Domain/InventoryTransaction.fs index 1799abd38..9a6000f77 100644 --- a/equinox-fc/Domain/InventoryTransaction.fs +++ b/equinox-fc/Domain/InventoryTransaction.fs @@ -14,7 +14,7 @@ module Fc.InventoryTransaction module Events = let [] CategoryId = "InventoryTransaction" - let (|For|) (inventoryId, transactionId) = FsCodec.StreamName.create CategoryId (InventoryTransactionId.toString transactionId) + let (|For|) transactionId = FsCodec.StreamName.create CategoryId (InventoryTransactionId.toString transactionId) type AdjustmentRequested = { location : LocationId; quantity : int } type TransferRequested = { source : LocationId; destination : LocationId; quantity : int } @@ -83,7 +83,6 @@ module Fold = (* Any disallowed state changes represent gaps in the model, so we fail fast *) | state, event -> failwithf "Unexpected %A when %A" event state - let fold : State -> Events.Event seq -> State = Seq.fold evolve type Command = @@ -112,7 +111,11 @@ let decide command updates (state : Fold.State) : Result * Events.Event list = match state, command with | Fold.Initial, Adjust e -> true, [ Events.AdjustmentRequested e ] | Fold.Initial, Transfer e -> true, [ Events.TransferRequested e ] + + // TOCONSIDER validate conflicts + | _ -> false, [] + { complete = false }, acc.Accumulated type Service internal (log, resolve, maxAttempts) = @@ -120,7 +123,7 @@ type Service internal (log, resolve, maxAttempts) = let resolve (Events.For streamId) = Equinox.Stream(log, resolve streamId, maxAttempts) member __.IngestShipped(inventoryId, transactionId, command, updates) : Async = - let stream = resolve (inventoryId, transactionId) + let stream = resolve transactionId stream.Transact(decide command updates) let createService resolve = Service(Serilog.Log.ForContext(), resolve, maxAttempts = 2) From 40d5b2b9f05e543542e12055e0a5b19208fe842e Mon Sep 17 00:00:00 2001 From: Ruben Bartelink Date: Mon, 17 Feb 2020 14:52:53 +0000 Subject: [PATCH 23/51] Remove checkpoints from InventorySeries --- equinox-fc/Domain.Tests/Domain.Tests.fsproj | 2 +- .../Domain.Tests/LocationSeriesTests.fs | 6 ++-- equinox-fc/Domain/Domain.fsproj | 3 +- equinox-fc/Domain/Inventory.fs | 2 +- equinox-fc/Domain/InventorySeries.fs | 35 ++++++------------- equinox-fc/Domain/LocationSeries.fs | 8 ++--- equinox-fc/README.md | 2 +- 7 files changed, 22 insertions(+), 36 deletions(-) diff --git a/equinox-fc/Domain.Tests/Domain.Tests.fsproj b/equinox-fc/Domain.Tests/Domain.Tests.fsproj index 40feab0a9..236935241 100644 --- a/equinox-fc/Domain.Tests/Domain.Tests.fsproj +++ b/equinox-fc/Domain.Tests/Domain.Tests.fsproj @@ -3,8 +3,8 @@ netcoreapp2.1 5 - Fc.Domain.Tests false + true diff --git a/equinox-fc/Domain.Tests/LocationSeriesTests.fs b/equinox-fc/Domain.Tests/LocationSeriesTests.fs index 6c7b33ae7..65f682545 100644 --- a/equinox-fc/Domain.Tests/LocationSeriesTests.fs +++ b/equinox-fc/Domain.Tests/LocationSeriesTests.fs @@ -14,7 +14,7 @@ let [] properties c1 c2 = | n, [], activeEpoch when n < epoch0 -> test <@ None = activeEpoch @> // Any >=0 value should trigger a Started event, initially - | n, [Events.Started { epochId = ee }], Some activatedEpoch -> + | n, [Events.Started { epoch = ee }], Some activatedEpoch -> test <@ n >= epoch0 && n = ee && n = activatedEpoch @> // Nothing else should yield events | _, l, _ -> @@ -27,11 +27,11 @@ let [] properties c1 c2 = | None, n, [], activeEpoch when n < epoch0 -> test <@ None = activeEpoch @> // Any >= 0 epochId should trigger a Started event if first command didnt do anything - | None, n, [Events.Started { epochId = ee }], Some activatedEpoch -> + | None, n, [Events.Started { epoch = ee }], Some activatedEpoch -> let eEpoch = %ee test <@ n >= epoch0 && n = eEpoch && n = activatedEpoch @> // Any higher epochId should trigger a Started event (gaps are fine - we are only tying to reduce walks) - | Some s1, n, [Events.Started { epochId = ee }], Some activatedEpoch -> + | Some s1, n, [Events.Started { epoch = ee }], Some activatedEpoch -> let eEpoch = %ee test <@ n > s1 && n = eEpoch && n > epoch0 && n = activatedEpoch @> // Nothing else should yield events diff --git a/equinox-fc/Domain/Domain.fsproj b/equinox-fc/Domain/Domain.fsproj index 1ac6eac73..32f7f3857 100644 --- a/equinox-fc/Domain/Domain.fsproj +++ b/equinox-fc/Domain/Domain.fsproj @@ -1,10 +1,9 @@  - netstandard2.0 + netstandard2.0 5 true - Fc.Domain diff --git a/equinox-fc/Domain/Inventory.fs b/equinox-fc/Domain/Inventory.fs index cf7a9301b..61d537212 100644 --- a/equinox-fc/Domain/Inventory.fs +++ b/equinox-fc/Domain/Inventory.fs @@ -22,7 +22,7 @@ type Service internal (inventoryId, series : Series.Service, epochs : Epoch.Serv // We want max one request in flight to establish the pre-existing Batches from which the tickets cache will be seeded let previousEpochs = AsyncCacheCell> list> <| async { - let! startingId = series.ReadIngestionEpochId(inventoryId) + let! startingId = series.ReadIngestionEpoch(inventoryId) activeEpochId <- %startingId let read epochId = async { let! r = epochs.TryIngest(inventoryId, epochId, (fun _ -> 1),Seq.empty) in return r.transactionIds } return [ for epoch in (max 0 (%startingId - lookBack)) .. (%startingId - 1) -> AsyncCacheCell(read %epoch) ] } diff --git a/equinox-fc/Domain/InventorySeries.fs b/equinox-fc/Domain/InventorySeries.fs index 33b7e2049..a0197d5d3 100644 --- a/equinox-fc/Domain/InventorySeries.fs +++ b/equinox-fc/Domain/InventorySeries.fs @@ -1,7 +1,6 @@ /// Manages a) the ingestion epoch id b) the current checkpointed read position for a long-running Inventory Series /// See InventoryEpoch for the logic managing the actual events logged within a given epoch /// See Inventory.Service for the surface API which manages the writing - module Fc.Inventory.Series // NB - these types and the union case names reflect the actual storage formats and hence need to be versioned with care @@ -11,49 +10,38 @@ module Events = let [] CategoryId = "InventorySeries" let (|For|) inventoryId = FsCodec.StreamName.create CategoryId (InventoryId.toString inventoryId) - type Epoch = { epoch : InventoryEpochId } - type Checkpoint = { epoch : InventoryEpochId; index : int64 } - type Snapshotted = { writing : InventoryEpochId; checkpoint : Checkpoint option } + type Started = { epoch : InventoryEpochId } type Event = - | Started of Epoch - | Checkpointed of Checkpoint - | Snapshotted of Snapshotted + | Started of Started interface TypeShape.UnionContract.IUnionContract let codec = FsCodec.NewtonsoftJson.Codec.Create() module Fold = - type State = { writing : InventoryEpochId; checkpoint : Events.Checkpoint option } - - let initial = { writing = InventoryEpochId.parse 0; checkpoint = None } - let evolve state = function - | Events.Started e -> { state with writing = e.epoch } - | Events.Checkpointed e -> { state with checkpoint = Some e } - | Events.Snapshotted e -> { writing = e.writing; checkpoint = e.checkpoint } + type State = InventoryEpochId option + let initial = None + let evolve _state = function + | Events.Started e -> Some e.epoch let fold : State -> Events.Event seq -> State = Seq.fold evolve - let isOrigin = function Events.Snapshotted _ -> true | _ -> false - let snapshot s = Events.Snapshotted { writing = s.writing; checkpoint = s.checkpoint } - // TODO transmute to remove checkpoints a la Propulsion +let queryActiveEpoch state = state |> Option.defaultValue (InventoryEpochId.parse 0) let interpretAdvanceIngestionEpoch epochId (state : Fold.State) = - if state.writing >= epochId then [] + if queryActiveEpoch state >= epochId then [] else [Events.Started { epoch = epochId }] type Service internal (log, resolve, maxAttempts) = let resolve (Events.For streamId) = Equinox.Stream(log, resolve streamId, maxAttempts) - member __.ReadIngestionEpochId inventoryId : Async = + member __.ReadIngestionEpoch(inventoryId) : Async = let stream = resolve inventoryId - stream.Query(fun s -> s.writing) + stream.Query queryActiveEpoch member __.AdvanceIngestionEpoch(inventoryId, epochId) : Async = let stream = resolve inventoryId stream.Transact(interpretAdvanceIngestionEpoch epochId) - // TODO checkpoint writing - let createService resolve = Service(Serilog.Log.ForContext(), resolve, maxAttempts = 2) @@ -61,13 +49,12 @@ module Cosmos = open Equinox.Cosmos - let accessStrategy = AccessStrategy.Snapshot (Fold.isOrigin, Fold.snapshot) let resolve (context, cache) = let cacheStrategy = CachingStrategy.SlidingWindow (cache, System.TimeSpan.FromMinutes 20.) // For this stream, we uniformly use stale reads as: // a) we don't require any information from competing writers // b) while there are competing writers [which might cause us to have to retry a Transact], this should be infrequent let opt = Equinox.ResolveOption.AllowStale - fun id -> Resolver(context, Events.codec, Fold.fold, Fold.initial, cacheStrategy, accessStrategy).Resolve(id, opt) + fun id -> Resolver(context, Events.codec, Fold.fold, Fold.initial, cacheStrategy, AccessStrategy.LatestKnownEvent).Resolve(id, opt) let createService (context, cache) = createService (resolve (context, cache)) diff --git a/equinox-fc/Domain/LocationSeries.fs b/equinox-fc/Domain/LocationSeries.fs index db922e71b..517f9dec7 100644 --- a/equinox-fc/Domain/LocationSeries.fs +++ b/equinox-fc/Domain/LocationSeries.fs @@ -6,9 +6,9 @@ module Fc.Location.Series module Events = let [] CategoryId = "LocationSeries" - let (|For|) id = FsCodec.StreamName.create CategoryId (LocationId.toString id) + let (|For|) locationId = FsCodec.StreamName.create CategoryId (LocationId.toString locationId) - type Started = { epochId : LocationEpochId } + type Started = { epoch : LocationEpochId } type Event = | Started of Started interface TypeShape.UnionContract.IUnionContract @@ -19,13 +19,13 @@ module Fold = type State = LocationEpochId option let initial : State = None let private evolve _state = function - | Events.Started e -> Some e.epochId + | Events.Started e -> Some e.epoch let fold = Seq.fold evolve let interpretAdvanceIngestionEpoch (epochId : LocationEpochId) (state : Fold.State) = if epochId < LocationEpochId.parse 0 then [] else - [if state |> Option.forall (fun s -> s < epochId) then yield Events.Started { epochId = epochId }] + [if state |> Option.forall (fun s -> s < epochId) then yield Events.Started { epoch = epochId }] type Service internal (log, resolve, maxAttempts) = diff --git a/equinox-fc/README.md b/equinox-fc/README.md index 39e27fd7c..350598cff 100644 --- a/equinox-fc/README.md +++ b/equinox-fc/README.md @@ -19,7 +19,7 @@ The `Location`* `module`s illustrates a way to approach the modelling of a long- - Referencing an `Equinox.*` Store module from the `Domain` project is not mandatory; it's common to defer all wiring and configuration of the elements in `module Cosmos`, `module EventStore` etc. and instead maintain that alongside the Composition Root, outside of the `Domain` project -- While using an `AccessStrategy` such as `Snapshotting` may in some cases be relevant too, in the general case, using the `Equinox.Cache`, combined with having a compact Fold `State` and a sufficiently constrained maximum number/size of events means the state can be established within a predictable latency range. +- While using an `AccessStrategy` such as `Snapshot` may in some cases be relevant too, in the general case, using the `Equinox.Cache`, combined with having a compact Fold `State` and a sufficiently constrained maximum number/size of events means the state can be established within a predictable latency range. - Representing a long-running state in this fashion is no panacea; in modeling a system, the ideal is to have streams that have a naturally constrained number of events over their lifetime. From 21f50c943ef6848bef5b9f6f9e44d1f840522fcb Mon Sep 17 00:00:00 2001 From: Ruben Bartelink Date: Mon, 17 Feb 2020 15:38:53 +0000 Subject: [PATCH 24/51] Tidying / formatting --- equinox-fc/Domain.Tests/LocationEpochTests.fs | 8 ++++---- equinox-fc/Domain.Tests/LocationTests.fs | 6 +++--- equinox-fc/Domain/Inventory.fs | 8 ++++---- equinox-fc/Domain/InventoryEpoch.fs | 10 +++++----- equinox-fc/Domain/InventorySeries.fs | 2 +- equinox-fc/Domain/InventoryTransaction.fs | 11 +++++------ equinox-fc/Domain/Location.fs | 2 +- equinox-fc/Domain/LocationEpoch.fs | 2 +- equinox-fc/Domain/LocationSeries.fs | 2 +- 9 files changed, 25 insertions(+), 26 deletions(-) diff --git a/equinox-fc/Domain.Tests/LocationEpochTests.fs b/equinox-fc/Domain.Tests/LocationEpochTests.fs index 49c09a264..b0c244314 100644 --- a/equinox-fc/Domain.Tests/LocationEpochTests.fs +++ b/equinox-fc/Domain.Tests/LocationEpochTests.fs @@ -6,7 +6,7 @@ open Swensen.Unquote let interpret transactionId delta _balance = match delta with - | 0 -> (),[] + | 0 -> (), [] | delta when delta < 0 -> (), [Events.Removed { delta = -delta; transaction = transactionId }] | delta -> (), [Events.Added { delta = delta; transaction = transactionId }] @@ -16,14 +16,14 @@ let validateAndInterpret transactionId expectedBalance delta balance = let verifyDeltaEvent transactionId delta events = let dEvents = events |> List.filter (function Events.Added _ | Events.Removed _ -> true | _ -> false) - test <@ interpret transactionId delta (Unchecked.defaultof<_>) = ((),dEvents) @> + test <@ interpret transactionId delta (Unchecked.defaultof<_>) = ((), dEvents) @> let [] properties transactionId carriedForward delta1 closeImmediately delta2 close = (* Starting with an empty stream, we'll need to supply the balance carried forward, optionally we apply a delta and potentially close *) let initialShouldClose _state = closeImmediately - let res,events = + let res, events = sync (Some carriedForward) (validateAndInterpret transactionId carriedForward delta1) initialShouldClose Fold.initial let cfEvents events = events |> List.filter (function Events.CarriedForward _ -> true | _ -> false) let closeEvents events = events |> List.filter (function Events.Closed -> true | _ -> false) @@ -40,7 +40,7 @@ let [] properties transactionId carriedForward delta1 closeImmediately (* After initializing, validate we don't need to supply a carriedForward, and don't produce a CarriedForward event *) let shouldClose _state = close - let { isOpen = isOpen; result = worked; balance = bal },events = + let { isOpen = isOpen; result = worked; balance = bal }, events = sync None (validateAndInterpret transactionId expectedBalance delta2) shouldClose state1 let expectedBalance = if expectImmediateClose then expectedBalance else expectedBalance + delta2 test <@ [] = cfEvents events diff --git a/equinox-fc/Domain.Tests/LocationTests.fs b/equinox-fc/Domain.Tests/LocationTests.fs index 03a3972bb..52e6b75f7 100644 --- a/equinox-fc/Domain.Tests/LocationTests.fs +++ b/equinox-fc/Domain.Tests/LocationTests.fs @@ -40,12 +40,12 @@ let run (service : Location.Service) (IdsAtLeastOne locations, deltas : _[], tra if value = 0 then 0, [] elif value < 0 then value, [Epoch.Events.Removed { delta = -value; transaction = transactionId }] else value, [Epoch.Events.Added { delta = value; transaction = transactionId }] - let! appliedDeltas = seq { for loc,x in updates -> async { let! _,eff = service.Execute(loc, adjust x) in return loc,eff } } |> Async.Parallel - let expectedBalances = Seq.append (seq { for l in locations -> l, 0}) appliedDeltas |> Seq.groupBy fst |> Seq.map (fun (l,xs) -> l, xs |> Seq.sumBy snd) |> Set.ofSeq + let! appliedDeltas = seq { for loc, x in updates -> async { let! _, eff = service.Execute(loc, adjust x) in return loc,eff } } |> Async.Parallel + let expectedBalances = Seq.append (seq { for l in locations -> l, 0}) appliedDeltas |> Seq.groupBy fst |> Seq.map (fun (l, xs) -> l, xs |> Seq.sumBy snd) |> Set.ofSeq (* Verify loading yields identical state *) - let! balances = seq { for loc in locations -> async { let! bal,() = service.Execute(loc,(fun _ -> (),[])) in return loc,bal } } |> Async.Parallel + let! balances = seq { for loc in locations -> async { let! bal, () = service.Execute(loc,(fun _ -> (), [])) in return loc,bal } } |> Async.Parallel test <@ expectedBalances = Set.ofSeq balances @> } let [] ``MemoryStore properties`` maxEvents args = diff --git a/equinox-fc/Domain/Inventory.fs b/equinox-fc/Domain/Inventory.fs index 61d537212..80b916343 100644 --- a/equinox-fc/Domain/Inventory.fs +++ b/equinox-fc/Domain/Inventory.fs @@ -5,7 +5,7 @@ open Equinox.Core // we use Equinox's AsyncCacheCell helper below open FSharp.UMX type internal IdsCache<'Id>() = - let all = System.Collections.Concurrent.ConcurrentDictionary<'Id,unit>() // Bounded only by relatively low number of physical pick tickets IRL + let all = System.Collections.Concurrent.ConcurrentDictionary<'Id, unit>() // Bounded only by relatively low number of physical pick tickets IRL static member Create init = let x = IdsCache() in x.Add init; x member __.Add ids = for x in ids do all.[x] <- () member __.Contains id = all.ContainsKey id @@ -24,7 +24,7 @@ type Service internal (inventoryId, series : Series.Service, epochs : Epoch.Serv let previousEpochs = AsyncCacheCell> list> <| async { let! startingId = series.ReadIngestionEpoch(inventoryId) activeEpochId <- %startingId - let read epochId = async { let! r = epochs.TryIngest(inventoryId, epochId, (fun _ -> 1),Seq.empty) in return r.transactionIds } + let read epochId = async { let! r = epochs.TryIngest(inventoryId, epochId, (fun _ -> 1), Seq.empty) in return r.transactionIds } return [ for epoch in (max 0 (%startingId - lookBack)) .. (%startingId - 1) -> AsyncCacheCell(read %epoch) ] } // TransactionIds cache - used to maintain a list of transactions that have already been ingested in order to avoid db round-trips @@ -39,7 +39,7 @@ type Service internal (inventoryId, series : Series.Service, epochs : Epoch.Serv let rec aux epochId totalIngested items = async { let SeqPartition f = Seq.toArray >> Array.partition f - let dup,fresh = items |> SeqPartition (Epoch.Events.chooseInventoryTransactionId >> Option.exists previousIds.Contains) + let dup, fresh = items |> SeqPartition (Epoch.Events.chooseInventoryTransactionId >> Option.exists previousIds.Contains) let fullCount = List.length items let dropping = fullCount - Array.length fresh if dropping <> 0 then log.Information("Ignoring {count}/{fullCount} duplicate ids: {ids} for {epochId}", dropping, fullCount, dup, epochId) @@ -54,7 +54,7 @@ type Service internal (inventoryId, series : Series.Service, epochs : Epoch.Serv if not res.isClosed && activeEpochId < %epochId then log.Information("Marking {inventoryId:l}/{epochId} active", inventoryId, epochId) do! series.AdvanceIngestionEpoch(inventoryId, epochId) - System.Threading.Interlocked.CompareExchange(&activeEpochId, %epochId,activeEpochId) |> ignore + System.Threading.Interlocked.CompareExchange(&activeEpochId, %epochId, activeEpochId) |> ignore let totalIngestedTickets = totalIngested + res.added match res.rejected with | [] -> return totalIngestedTickets diff --git a/equinox-fc/Domain/InventoryEpoch.fs b/equinox-fc/Domain/InventoryEpoch.fs index d8fafbe42..c596d5cbb 100644 --- a/equinox-fc/Domain/InventoryEpoch.fs +++ b/equinox-fc/Domain/InventoryEpoch.fs @@ -55,25 +55,25 @@ let decideSync capacity events (state : Fold.State) : Result * Events.Event list | Events.Transferred { transactionId = id } -> (not << state.ids.Contains) id | Events.Closed | Events.Snapshotted _ -> false let news = events |> Seq.filter isFresh |> List.ofSeq - let closed,allowing,markClosed,residual = + let closed, allowing, markClosed, residual = let newCount = List.length news if state.closed then - true,0,false,news + true, 0, false, news else let capacityNow = capacity state let accepting = min capacityNow newCount let closing = accepting = capacityNow let residual = List.skip accepting news - closing,accepting,closing,residual + closing, accepting, closing, residual let events = [ if allowing <> 0 then yield! news if markClosed then yield Events.Closed ] let state' = Fold.fold state events - { isClosed = closed; added = allowing; rejected = residual; transactionIds = state'.ids },events + { isClosed = closed; added = allowing; rejected = residual; transactionIds = state'.ids }, events type Service internal (log, resolve, maxAttempts) = - let resolve (Events.For streamId) = Equinox.Stream(log, resolve streamId, maxAttempts) + let resolve (Events.For streamId) = Equinox.Stream(log, resolve streamId, maxAttempts) /// Attempt ingestion of `events` into the cited Epoch. /// - None will be accepted if the Epoch is `closed` diff --git a/equinox-fc/Domain/InventorySeries.fs b/equinox-fc/Domain/InventorySeries.fs index a0197d5d3..2e0ce93fe 100644 --- a/equinox-fc/Domain/InventorySeries.fs +++ b/equinox-fc/Domain/InventorySeries.fs @@ -32,7 +32,7 @@ let interpretAdvanceIngestionEpoch epochId (state : Fold.State) = type Service internal (log, resolve, maxAttempts) = - let resolve (Events.For streamId) = Equinox.Stream(log, resolve streamId, maxAttempts) + let resolve (Events.For streamId) = Equinox.Stream(log, resolve streamId, maxAttempts) member __.ReadIngestionEpoch(inventoryId) : Async = let stream = resolve inventoryId diff --git a/equinox-fc/Domain/InventoryTransaction.fs b/equinox-fc/Domain/InventoryTransaction.fs index 9a6000f77..7d5c4d988 100644 --- a/equinox-fc/Domain/InventoryTransaction.fs +++ b/equinox-fc/Domain/InventoryTransaction.fs @@ -2,7 +2,7 @@ /// - Coordinate competing attempts to transfer quantities from stock; if balance is 3 one of contesting requests to remove 2 or 3 items must reach `Failed` /// - maintain rolling balance of stock levels per Location /// - while recording any transfers or adjustment in an overall Inventory record -/// The Process is driven by two actors: +/// The Process is driven by two collaborating actors: /// 1) The 'happy path', where a given actor is executing the known steps of the command flow /// In the normal case, such an actor will bring the flow to a terminal state (Completed or Failed) /// 2) A watchdog-projector, which reacts to observed events in this Category by stepping in to complete in-flight requests that have stalled @@ -51,13 +51,12 @@ module Fold = | Failed of Events.TransferRequested | Adding of Removed | Added of Added + and Removed = { request : Events.TransferRequested; removed : Events.Removed } + and Added = { request : Events.TransferRequested; removed : Events.Removed; added : Events.Added } and TerminalState = | Adjusted of Events.AdjustmentRequested | Transferred of Added | TransferFailed of Events.TransferRequested - and AdjustState = Events.AdjustmentRequested - and Removed = { request : Events.TransferRequested; removed : Events.Removed } - and Added = { request : Events.TransferRequested; removed : Events.Removed; added : Events.Added } let initial = Initial let evolve state event = match state, event with @@ -120,7 +119,7 @@ let decide command updates (state : Fold.State) : Result * Events.Event list = type Service internal (log, resolve, maxAttempts) = - let resolve (Events.For streamId) = Equinox.Stream(log, resolve streamId, maxAttempts) + let resolve (Events.For streamId) = Equinox.Stream(log, resolve streamId, maxAttempts) member __.IngestShipped(inventoryId, transactionId, command, updates) : Async = let stream = resolve transactionId @@ -139,4 +138,4 @@ module Cosmos = let resolve (context, cache) = let cacheStrategy = CachingStrategy.SlidingWindow (cache, System.TimeSpan.FromMinutes 20.) fun id -> Resolver(context, Events.codec, Fold.fold, Fold.initial, cacheStrategy, accessStrategy).Resolve(id, opt) - let createService (context, cache) = createService (resolve (context, cache)) \ No newline at end of file + let createService (context, cache) = createService (resolve (context, cache)) diff --git a/equinox-fc/Domain/Location.fs b/equinox-fc/Domain/Location.fs index 7527bde77..838f9d174 100644 --- a/equinox-fc/Domain/Location.fs +++ b/equinox-fc/Domain/Location.fs @@ -10,7 +10,7 @@ type Service internal (zeroBalance, shouldClose, series : Series.Service, epochs let rec execute locationId originEpochId = let rec aux epochId balanceToCarryForward wip = async { - let decide state = match wip with Complete r -> r,[] | Pending decide -> decide state + let decide state = match wip with Complete r -> r, [] | Pending decide -> decide state match! epochs.Sync(locationId, epochId, balanceToCarryForward, decide, shouldClose) with | { balance = bal; result = Some res; isOpen = true } -> if originEpochId <> epochId then diff --git a/equinox-fc/Domain/LocationEpoch.fs b/equinox-fc/Domain/LocationEpoch.fs index 24e9d3b5d..421b7a052 100644 --- a/equinox-fc/Domain/LocationEpoch.fs +++ b/equinox-fc/Domain/LocationEpoch.fs @@ -64,7 +64,7 @@ let sync (balanceCarriedForward : Fold.Balance option) (decide : Fold.Balance -> acc.Ingest state <| match state with | Fold.Initial -> failwith "We've just guaranteed not Initial" - | Fold.Open { value = bal } -> let r,es = decide bal in Some r,es + | Fold.Open { value = bal } -> let r, es = decide bal in Some r, es | Fold.Closed _ -> None, [] // Finally (iff we're `Open`, have run a `decide` and `shouldClose`), we generate a Closed event let (balance, isOpen), _ = diff --git a/equinox-fc/Domain/LocationSeries.fs b/equinox-fc/Domain/LocationSeries.fs index 517f9dec7..72306eb40 100644 --- a/equinox-fc/Domain/LocationSeries.fs +++ b/equinox-fc/Domain/LocationSeries.fs @@ -48,6 +48,6 @@ module Cosmos = let resolve (context, cache) = let cacheStrategy = CachingStrategy.SlidingWindow (cache, System.TimeSpan.FromMinutes 20.) let opt = Equinox.ResolveOption.AllowStale - fun id -> Resolver(context, Events.codec, Fold.fold, Fold.initial, cacheStrategy, AccessStrategy.LatestKnownEvent).Resolve(id,opt) + fun id -> Resolver(context, Events.codec, Fold.fold, Fold.initial, cacheStrategy, AccessStrategy.LatestKnownEvent).Resolve(id, opt) let createService (context, cache, maxAttempts) = create (resolve (context, cache)) maxAttempts From eba71f9ec0b3f796f077cec878b5c5d8e52993a1 Mon Sep 17 00:00:00 2001 From: Ruben Bartelink Date: Mon, 17 Feb 2020 15:48:09 +0000 Subject: [PATCH 25/51] Fix batch vs epoch naming in inventory --- equinox-fc/Domain/Inventory.fs | 28 ++++++++++++++-------------- equinox-fc/Domain/InventoryEpoch.fs | 4 ++-- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/equinox-fc/Domain/Inventory.fs b/equinox-fc/Domain/Inventory.fs index 80b916343..f512547ad 100644 --- a/equinox-fc/Domain/Inventory.fs +++ b/equinox-fc/Domain/Inventory.fs @@ -20,7 +20,7 @@ type Service internal (inventoryId, series : Series.Service, epochs : Epoch.Serv // Guaranteed to be set only after `previousIds.AwaitValue()` let mutable activeEpochId = Unchecked.defaultof<_> - // We want max one request in flight to establish the pre-existing Batches from which the tickets cache will be seeded + // We want max one request in flight to establish the pre-existing Events from which the TransactionIds cache will be seeded let previousEpochs = AsyncCacheCell> list> <| async { let! startingId = series.ReadIngestionEpoch(inventoryId) activeEpochId <- %startingId @@ -29,8 +29,8 @@ type Service internal (inventoryId, series : Series.Service, epochs : Epoch.Serv // TransactionIds cache - used to maintain a list of transactions that have already been ingested in order to avoid db round-trips let previousIds : AsyncCacheCell> = AsyncCacheCell <| async { - let! batches = previousEpochs.AwaitValue() - let! ids = seq { for x in batches -> x.AwaitValue() } |> Async.Parallel + let! previousEpochs = previousEpochs.AwaitValue() + let! ids = seq { for x in previousEpochs -> x.AwaitValue() } |> Async.Parallel return IdsCache.Create(Seq.concat ids) } let tryIngest events = async { @@ -55,30 +55,30 @@ type Service internal (inventoryId, series : Series.Service, epochs : Epoch.Serv log.Information("Marking {inventoryId:l}/{epochId} active", inventoryId, epochId) do! series.AdvanceIngestionEpoch(inventoryId, epochId) System.Threading.Interlocked.CompareExchange(&activeEpochId, %epochId, activeEpochId) |> ignore - let totalIngestedTickets = totalIngested + res.added + let totalIngestedTransactions = totalIngested + res.added match res.rejected with - | [] -> return totalIngestedTickets - | rej -> return! aux (InventoryEpochId.next epochId) totalIngestedTickets rej } + | [] -> return totalIngestedTransactions + | rej -> return! aux (InventoryEpochId.next epochId) totalIngestedTransactions rej } return! aux initialEpochId 0 events } - /// Upon startup, we initialize the PickTickets cache with recent batches; we want to kick that process off before our first ingest + /// Upon startup, we initialize the TransactionIds cache with recent epochs; we want to kick that process off before our first ingest member __.Initialize() = previousIds.AwaitValue() |> Async.Ignore - /// Feeds the events into the sequence of batches. Returns the number of items actually added [excluding duplicates] + /// Feeds the events into the sequence of transactions. Returns the number actually added [excluding duplicates] member __.Ingest(events : Epoch.Events.Event list) : Async = tryIngest events module internal Helpers = - let create inventoryId maxTransactionsPerBatch lookBackLimit (series, epochs) = - let remainingBatchCapacity (state: Epoch.Fold.State) = + let create inventoryId maxTransactionsPerEpoch lookBackLimit (series, epochs) = + let remainingEpochCapacity (state: Epoch.Fold.State) = let currentLen = state.ids.Count - max 0 (maxTransactionsPerBatch - currentLen) - Service(inventoryId, series, epochs, lookBack = lookBackLimit, capacity = remainingBatchCapacity) + max 0 (maxTransactionsPerEpoch - currentLen) + Service(inventoryId, series, epochs, lookBack = lookBackLimit, capacity = remainingEpochCapacity) module Cosmos = - let create inventoryId maxTransactionsPerBatch lookBackLimit (context, cache) = + let create inventoryId maxTransactionsPerEpoch lookBackLimit (context, cache) = let series = Series.Cosmos.createService (context, cache) let epochs = Epoch.Cosmos.createService (context, cache) - Helpers.create inventoryId maxTransactionsPerBatch lookBackLimit (series, epochs) + Helpers.create inventoryId maxTransactionsPerEpoch lookBackLimit (series, epochs) diff --git a/equinox-fc/Domain/InventoryEpoch.fs b/equinox-fc/Domain/InventoryEpoch.fs index c596d5cbb..746c01b49 100644 --- a/equinox-fc/Domain/InventoryEpoch.fs +++ b/equinox-fc/Domain/InventoryEpoch.fs @@ -66,8 +66,8 @@ let decideSync capacity events (state : Fold.State) : Result * Events.Event list let residual = List.skip accepting news closing, accepting, closing, residual let events = - [ if allowing <> 0 then yield! news - if markClosed then yield Events.Closed ] + [ if allowing <> 0 then yield! news + if markClosed then yield Events.Closed ] let state' = Fold.fold state events { isClosed = closed; added = allowing; rejected = residual; transactionIds = state'.ids }, events From d05eab2127346a00168ba1f5d093a9a42d178990 Mon Sep 17 00:00:00 2001 From: Ruben Bartelink Date: Tue, 18 Feb 2020 08:44:25 +0000 Subject: [PATCH 26/51] Complete Process Manager Apply --- equinox-fc/Domain/InventoryTransaction.fs | 80 ++++++++++------------- 1 file changed, 36 insertions(+), 44 deletions(-) diff --git a/equinox-fc/Domain/InventoryTransaction.fs b/equinox-fc/Domain/InventoryTransaction.fs index 7d5c4d988..57c845d79 100644 --- a/equinox-fc/Domain/InventoryTransaction.fs +++ b/equinox-fc/Domain/InventoryTransaction.fs @@ -33,7 +33,8 @@ module Events = | Added of Added (* Successful completion *) - | Completed // terminal + | Logged + | Completed interface TypeShape.UnionContract.IUnionContract let codec = FsCodec.NewtonsoftJson.Codec.Create() @@ -42,6 +43,7 @@ module Fold = type State = | Initial | Running of RunningState + | Logging of TerminalState | Completed of TerminalState and RunningState = | Adjust of Events.AdjustmentRequested @@ -61,10 +63,10 @@ module Fold = let evolve state event = match state, event with (* Adjustment Process *) - | Initial, Events.AdjustmentRequested e -> - Running (Adjust e) - | Running (Adjust s), Events.Adjusted -> - Completed (Adjusted s) + | Initial, Events.AdjustmentRequested r -> + Running (Adjust r) + | Running (Adjust r), Events.Adjusted -> + Logging (Adjusted r) (* Transfer Process *) | Initial, Events.TransferRequested e -> @@ -73,59 +75,49 @@ module Fold = | Running (Transfer (Requested s)), Events.Failed -> Completed (TransferFailed s) - | Running (Transfer (Requested s)), Events.Removed e as ee -> + | Running (Transfer (Requested s)), Events.Removed e -> Running (Transfer (Adding { request = s; removed = e })) - | Running (Transfer (Adding s)), Events.Added e as ee -> + | Running (Transfer (Adding s)), Events.Added e -> Running (Transfer (Added { request = s.request; removed = s.removed; added = e })) - | Running (Transfer (Added s)), Events.Completed as ee -> - Completed (Transferred s) + | Running (Transfer (Added s)), Events.Completed -> + Logging (Transferred s) + + (* Log result *) + | Logging s, Events.Logged -> + Completed s (* Any disallowed state changes represent gaps in the model, so we fail fast *) | state, event -> failwithf "Unexpected %A when %A" event state let fold : State -> Events.Event seq -> State = Seq.fold evolve -type Command = - | Adjust of Events.AdjustmentRequested - | Transfer of Events.TransferRequested - -type Result = { complete : bool; } - -/// Holds events accumulated from a series of decisions while also evolving the presented `state` to reflect the pended events -type private Accumulator() = - let acc = ResizeArray() - member __.Ingest(state) : 'res * Events.Event list -> 'res * Fold.State = function - | res, [] -> res, state - | res, [e] -> acc.Add e; res, Fold.evolve state e - | res, xs -> acc.AddRange xs; res, Fold.fold state (Seq.ofList xs) - member __.Accumulated = List.ofSeq acc - -type Update = - | AdjustmentCompleted - -let decide command updates (state : Fold.State) : Result * Events.Event list = - - let acc = Accumulator() - let started, state = - acc.Ingest state <| - match state, command with - | Fold.Initial, Adjust e -> true, [ Events.AdjustmentRequested e ] - | Fold.Initial, Transfer e -> true, [ Events.TransferRequested e ] - - // TOCONSIDER validate conflicts - - | _ -> false, [] - - { complete = false }, acc.Accumulated + /// Validates an event actually represents an acceptable, non-redundant state transition + let filter event state = + match state, event with + | Initial, Events.AdjustmentRequested _ + | Initial, Events.TransferRequested _ + | Running (Adjust _), Events.Adjusted + | Running (Transfer (Requested _)), Events.Failed + | Running (Transfer (Requested _)), Events.Removed _ + | Running (Transfer (Adding _)), Events.Added _ + | Logging _, Events.Logged -> + [event] + | _ -> [] + +/// Given an event from the Process's timeline, yields the State, in order that it can be completed +let decide update (state : Fold.State) : Fold.State * Events.Event list = + let events = Fold.filter update state + let state' = Fold.fold state events + state', events type Service internal (log, resolve, maxAttempts) = let resolve (Events.For streamId) = Equinox.Stream(log, resolve streamId, maxAttempts) - member __.IngestShipped(inventoryId, transactionId, command, updates) : Async = + member __.Apply(transactionId, update) : Async = let stream = resolve transactionId - stream.Transact(decide command updates) + stream.Transact(decide update) -let createService resolve = Service(Serilog.Log.ForContext(), resolve, maxAttempts = 2) +let createService resolve = Service(Serilog.Log.ForContext(), resolve, maxAttempts = 3) module Cosmos = From e3582b935b316b42b775d59b65392056b6cbd2c0 Mon Sep 17 00:00:00 2001 From: Ruben Bartelink Date: Wed, 19 Feb 2020 09:54:17 +0000 Subject: [PATCH 27/51] Target V2s --- equinox-fc/Domain.Tests/Domain.Tests.fsproj | 2 +- equinox-fc/Domain/Domain.fsproj | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/equinox-fc/Domain.Tests/Domain.Tests.fsproj b/equinox-fc/Domain.Tests/Domain.Tests.fsproj index 236935241..a8213b6eb 100644 --- a/equinox-fc/Domain.Tests/Domain.Tests.fsproj +++ b/equinox-fc/Domain.Tests/Domain.Tests.fsproj @@ -16,7 +16,7 @@ - + diff --git a/equinox-fc/Domain/Domain.fsproj b/equinox-fc/Domain/Domain.fsproj index 32f7f3857..13301d866 100644 --- a/equinox-fc/Domain/Domain.fsproj +++ b/equinox-fc/Domain/Domain.fsproj @@ -17,7 +17,7 @@ - + From 2c34ca2192fada2d28d75be389e7af40237c9b91 Mon Sep 17 00:00:00 2001 From: Ruben Bartelink Date: Wed, 19 Feb 2020 15:17:11 +0000 Subject: [PATCH 28/51] wip --- equinox-fc/.template.config/template.json | 2 +- equinox-fc/Domain/Domain.fsproj | 2 +- equinox-fc/Domain/Inventory.fs | 19 +++++++++-- equinox-fc/Domain/InventoryTransaction.fs | 40 +++++++++++++++-------- 4 files changed, 45 insertions(+), 18 deletions(-) diff --git a/equinox-fc/.template.config/template.json b/equinox-fc/.template.config/template.json index 135b57883..b8bac62f6 100644 --- a/equinox-fc/.template.config/template.json +++ b/equinox-fc/.template.config/template.json @@ -12,7 +12,7 @@ }, "identity": "Equinox.Fc", "name": "Equinox Fc Example", - "shortName": "eqxFc", + "shortName": "fcInventory", "sourceName": "Fc", "preferNameDirectory": true } \ No newline at end of file diff --git a/equinox-fc/Domain/Domain.fsproj b/equinox-fc/Domain/Domain.fsproj index 13301d866..9e1499c82 100644 --- a/equinox-fc/Domain/Domain.fsproj +++ b/equinox-fc/Domain/Domain.fsproj @@ -13,8 +13,8 @@ - + diff --git a/equinox-fc/Domain/Inventory.fs b/equinox-fc/Domain/Inventory.fs index f512547ad..9a13f5423 100644 --- a/equinox-fc/Domain/Inventory.fs +++ b/equinox-fc/Domain/Inventory.fs @@ -70,7 +70,7 @@ type Service internal (inventoryId, series : Series.Service, epochs : Epoch.Serv module internal Helpers = - let create inventoryId maxTransactionsPerEpoch lookBackLimit (series, epochs) = + let createService inventoryId maxTransactionsPerEpoch lookBackLimit (series, epochs) = let remainingEpochCapacity (state: Epoch.Fold.State) = let currentLen = state.ids.Count max 0 (maxTransactionsPerEpoch - currentLen) @@ -81,4 +81,19 @@ module Cosmos = let create inventoryId maxTransactionsPerEpoch lookBackLimit (context, cache) = let series = Series.Cosmos.createService (context, cache) let epochs = Epoch.Cosmos.createService (context, cache) - Helpers.create inventoryId maxTransactionsPerEpoch lookBackLimit (series, epochs) + Helpers.createService inventoryId maxTransactionsPerEpoch lookBackLimit (series, epochs) + +module Processor = + + type Service(transactions : Transaction.Service, locations : Fc.Location.Service, inventory : Service) = + + member __.Apply(inventoryId, transactionId, update) = async { + let! action = transactions.Apply(transactionId, update) + match action with + | Transaction.Adjust (loc, qty) -> locations.Execute + | Remove of LocationId * int + | Add of LocationId * int + | Log of TerminalState + | Finish + service.Ingest + } \ No newline at end of file diff --git a/equinox-fc/Domain/InventoryTransaction.fs b/equinox-fc/Domain/InventoryTransaction.fs index 57c845d79..90f4f0410 100644 --- a/equinox-fc/Domain/InventoryTransaction.fs +++ b/equinox-fc/Domain/InventoryTransaction.fs @@ -7,7 +7,7 @@ /// In the normal case, such an actor will bring the flow to a terminal state (Completed or Failed) /// 2) A watchdog-projector, which reacts to observed events in this Category by stepping in to complete in-flight requests that have stalled /// This represents the case where a 'happy path' actor died, or experienced another impediment on the path. -module Fc.InventoryTransaction +module Fc.Inventory.Transaction // NB - these types and the union case names reflect the actual storage formats and hence need to be versioned with care [] @@ -38,6 +38,18 @@ module Events = interface TypeShape.UnionContract.IUnionContract let codec = FsCodec.NewtonsoftJson.Codec.Create() +type TerminalState = + | Adjusted of Events.AdjustmentRequested + | Transferred of Added + | TransferFailed of Events.TransferRequested +and Added = { request : Events.TransferRequested; removed : Events.Removed; added : Events.Added } +type Action = + | Adjust of LocationId * int + | Remove of LocationId * int + | Add of LocationId * int + | Log of TerminalState + | Finish + module Fold = type State = @@ -50,15 +62,8 @@ module Fold = | Transfer of TransferState and TransferState = | Requested of Events.TransferRequested - | Failed of Events.TransferRequested | Adding of Removed - | Added of Added and Removed = { request : Events.TransferRequested; removed : Events.Removed } - and Added = { request : Events.TransferRequested; removed : Events.Removed; added : Events.Added } - and TerminalState = - | Adjusted of Events.AdjustmentRequested - | Transferred of Added - | TransferFailed of Events.TransferRequested let initial = Initial let evolve state event = match state, event with @@ -78,9 +83,7 @@ module Fold = | Running (Transfer (Requested s)), Events.Removed e -> Running (Transfer (Adding { request = s; removed = e })) | Running (Transfer (Adding s)), Events.Added e -> - Running (Transfer (Added { request = s.request; removed = s.removed; added = e })) - | Running (Transfer (Added s)), Events.Completed -> - Logging (Transferred s) + Logging (Transferred { request = s.request; removed = s.removed; added = e }) (* Log result *) | Logging s, Events.Logged -> @@ -103,17 +106,26 @@ module Fold = [event] | _ -> [] + /// Determines the next action (if any) to be carried out in this workflwo + let nextAction : State -> Action = function + | Initial -> failwith "Cannot interpret Initial state" + | Running (Adjust r) -> Action.Adjust (r.location, r.quantity) + | Running (Transfer (Requested r)) -> Action.Remove (r.source, r.quantity) + | Running (Transfer (Adding r)) -> Action.Add (r.request.destination, r.request.quantity) + | Logging s -> Action.Log s + | Completed _ -> Finish + /// Given an event from the Process's timeline, yields the State, in order that it can be completed -let decide update (state : Fold.State) : Fold.State * Events.Event list = +let decide update (state : Fold.State) : Action * Events.Event list = let events = Fold.filter update state let state' = Fold.fold state events - state', events + Fold.nextAction state', events type Service internal (log, resolve, maxAttempts) = let resolve (Events.For streamId) = Equinox.Stream(log, resolve streamId, maxAttempts) - member __.Apply(transactionId, update) : Async = + member __.Apply(transactionId, update) : Async = let stream = resolve transactionId stream.Transact(decide update) From ca7f60c4fc902fbdb2ded42d87f7cbe21579315e Mon Sep 17 00:00:00 2001 From: Ruben Bartelink Date: Fri, 21 Feb 2020 14:58:25 +0000 Subject: [PATCH 29/51] Process Manager wip --- equinox-fc/Domain/Inventory.fs | 50 ++++++--- equinox-fc/Domain/InventoryEpoch.fs | 7 +- equinox-fc/Domain/InventoryTransaction.fs | 27 +++-- equinox-fc/Domain/Location.fs | 28 ++--- equinox-fc/Domain/LocationEpoch.fs | 118 +++++++++++++++------- 5 files changed, 153 insertions(+), 77 deletions(-) diff --git a/equinox-fc/Domain/Inventory.fs b/equinox-fc/Domain/Inventory.fs index 9a13f5423..80943ea48 100644 --- a/equinox-fc/Domain/Inventory.fs +++ b/equinox-fc/Domain/Inventory.fs @@ -12,9 +12,9 @@ type internal IdsCache<'Id>() = /// Maintains active Epoch Id in a thread-safe manner while ingesting items into the `series` of `epochs` /// Prior to first add, reads `lookBack` epochs to seed the cache, in order to minimize the number of duplicated Ids we ingest -type Service internal (inventoryId, series : Series.Service, epochs : Epoch.Service, lookBack, capacity) = +type Service2 internal (inventoryId, series : Series.Service, epochs : Epoch.Service, lookBack, capacity) = - let log = Serilog.Log.ForContext() + let log = Serilog.Log.ForContext() // Maintains what we believe to be the currently open EpochId // Guaranteed to be set only after `previousIds.AwaitValue()` @@ -74,7 +74,7 @@ module internal Helpers = let remainingEpochCapacity (state: Epoch.Fold.State) = let currentLen = state.ids.Count max 0 (maxTransactionsPerEpoch - currentLen) - Service(inventoryId, series, epochs, lookBack = lookBackLimit, capacity = remainingEpochCapacity) + Service2(inventoryId, series, epochs, lookBack = lookBackLimit, capacity = remainingEpochCapacity) module Cosmos = @@ -85,15 +85,35 @@ module Cosmos = module Processor = - type Service(transactions : Transaction.Service, locations : Fc.Location.Service, inventory : Service) = - - member __.Apply(inventoryId, transactionId, update) = async { - let! action = transactions.Apply(transactionId, update) - match action with - | Transaction.Adjust (loc, qty) -> locations.Execute - | Remove of LocationId * int - | Add of LocationId * int - | Log of TerminalState - | Finish - service.Ingest - } \ No newline at end of file + type Service(transactions : Transaction.Service, locations : Fc.Location.Service, inventory : Service2) = + + let execute transactionId = + let f = Fc.Location.Epoch.decide transactionId + let rec aux update = async { + let! action = transactions.Apply(transactionId, update) + match action with + | Transaction.Adjust (loc, bal) -> + match! locations.Execute(loc, f (Fc.Location.Epoch.Reset bal)) with + | Fc.Location.Epoch.Accepted _ -> do! aux (Transaction.Events.Adjusted) + | Fc.Location.Epoch.DupFromPreviousEpoch -> failwith "TODO walk back to previous epoch" + | Transaction.Remove (loc, delta) -> + match! locations.Execute(loc, f (Fc.Location.Epoch.Remove delta)) with + | Fc.Location.Epoch.Accepted bal -> do! aux (Transaction.Events.Removed { balance = bal }) + | Fc.Location.Epoch.DupFromPreviousEpoch -> failwith "TODO walk back to previous epoch" + | Transaction.Add (loc, delta) -> + match! locations.Execute(loc, f (Fc.Location.Epoch.Add delta)) with + | Fc.Location.Epoch.Accepted bal -> do! aux (Transaction.Events.Added { balance = bal }) + | Fc.Location.Epoch.DupFromPreviousEpoch -> failwith "TODO walk back to previous epoch" + | Transaction.Log (Transaction.Adjusted e) -> + let! _count = inventory.Ingest([Fc.Inventory.Epoch.Events.Adjusted { transactionId = transactionId }]) + do! aux Transaction.Events.Logged + | Transaction.Log (Transaction.Transferred e) -> + let! _count = inventory.Ingest([Fc.Inventory.Epoch.Events.Transferred { transactionId = transactionId }]) + do! aux Transaction.Events.Logged + | Transaction.Finish -> + () + } + aux + + member __.Apply(transactionId, update) = + execute transactionId update diff --git a/equinox-fc/Domain/InventoryEpoch.fs b/equinox-fc/Domain/InventoryEpoch.fs index 746c01b49..5fca01731 100644 --- a/equinox-fc/Domain/InventoryEpoch.fs +++ b/equinox-fc/Domain/InventoryEpoch.fs @@ -10,13 +10,12 @@ module Events = let [] CategoryId = "InventoryEpoch" let (|For|) (inventoryId, epochId) = FsCodec.StreamName.compose CategoryId [InventoryId.toString inventoryId; InventoryEpochId.toString epochId] - type TransactionInfo = { transactionId : InventoryTransactionId } - + type TransactionRef = { transactionId : InventoryTransactionId } type Snapshotted = { closed: bool; ids : InventoryTransactionId[] } type Event = - | Adjusted of TransactionInfo - | Transferred of TransactionInfo + | Adjusted of TransactionRef + | Transferred of TransactionRef | Closed | Snapshotted of Snapshotted interface TypeShape.UnionContract.IUnionContract diff --git a/equinox-fc/Domain/InventoryTransaction.fs b/equinox-fc/Domain/InventoryTransaction.fs index 90f4f0410..31c168ce7 100644 --- a/equinox-fc/Domain/InventoryTransaction.fs +++ b/equinox-fc/Domain/InventoryTransaction.fs @@ -38,24 +38,23 @@ module Events = interface TypeShape.UnionContract.IUnionContract let codec = FsCodec.NewtonsoftJson.Codec.Create() -type TerminalState = - | Adjusted of Events.AdjustmentRequested - | Transferred of Added - | TransferFailed of Events.TransferRequested -and Added = { request : Events.TransferRequested; removed : Events.Removed; added : Events.Added } type Action = | Adjust of LocationId * int | Remove of LocationId * int | Add of LocationId * int - | Log of TerminalState + | Log of LoggingState | Finish +and LoggingState = + | Adjusted of Events.AdjustmentRequested + | Transferred of Added +and Added = { request : Events.TransferRequested; removed : Events.Removed; added : Events.Added } module Fold = type State = | Initial | Running of RunningState - | Logging of TerminalState + | Logging of LoggingState | Completed of TerminalState and RunningState = | Adjust of Events.AdjustmentRequested @@ -63,6 +62,10 @@ module Fold = and TransferState = | Requested of Events.TransferRequested | Adding of Removed + and TerminalState = + | Adjusted of Events.AdjustmentRequested + | Transferred of Added + | TransferFailed of Events.TransferRequested and Removed = { request : Events.TransferRequested; removed : Events.Removed } let initial = Initial let evolve state event = @@ -71,7 +74,7 @@ module Fold = | Initial, Events.AdjustmentRequested r -> Running (Adjust r) | Running (Adjust r), Events.Adjusted -> - Logging (Adjusted r) + Logging (LoggingState.Adjusted r) (* Transfer Process *) | Initial, Events.TransferRequested e -> @@ -83,11 +86,13 @@ module Fold = | Running (Transfer (Requested s)), Events.Removed e -> Running (Transfer (Adding { request = s; removed = e })) | Running (Transfer (Adding s)), Events.Added e -> - Logging (Transferred { request = s.request; removed = s.removed; added = e }) + Logging (LoggingState.Transferred { request = s.request; removed = s.removed; added = e }) (* Log result *) - | Logging s, Events.Logged -> - Completed s + | Logging (LoggingState.Adjusted s), Events.Logged -> + Completed (Adjusted s) + | Logging (LoggingState.Transferred s), Events.Logged -> + Completed (Transferred s) (* Any disallowed state changes represent gaps in the model, so we fail fast *) | state, event -> failwithf "Unexpected %A when %A" event state diff --git a/equinox-fc/Domain/Location.fs b/equinox-fc/Domain/Location.fs index 838f9d174..ed4fde94a 100644 --- a/equinox-fc/Domain/Location.fs +++ b/equinox-fc/Domain/Location.fs @@ -2,26 +2,28 @@ namespace Fc.Location [] type Wip<'R> = - | Pending of decide : (Epoch.Fold.Balance -> 'R * Epoch.Events.Event list) + | Pending of decide : (Epoch.Fold.State -> 'R * Epoch.Events.Event list) | Complete of 'R /// Manages Reads and Writes for a Series of Epochs, with a running total being carried forward to the next Epoch when it's marked Closed -type Service internal (zeroBalance, shouldClose, series : Series.Service, epochs : Epoch.Service) = +type Service internal (zeroBalance, toBalanceCarriedForward, shouldClose, series : Series.Service, epochs : Epoch.Service) = - let rec execute locationId originEpochId = + let execute locationId originEpochId = let rec aux epochId balanceToCarryForward wip = async { let decide state = match wip with Complete r -> r, [] | Pending decide -> decide state match! epochs.Sync(locationId, epochId, balanceToCarryForward, decide, shouldClose) with - | { balance = bal; result = Some res; isOpen = true } -> + | { result = Some res; isOpen = true } -> if originEpochId <> epochId then do! series.AdvanceIngestionEpoch(locationId, epochId) - return bal, res - | { balance = bal; result = Some res } -> + return res + | { history = history; result = Some res } -> let successorEpochId = LocationEpochId.next epochId - return! aux successorEpochId (Some bal) (Complete res) - | { balance = bal } -> + let cf = toBalanceCarriedForward history + return! aux successorEpochId (Some cf) (Complete res) + | { history = history } -> let successorEpochId = LocationEpochId.next epochId - return! aux successorEpochId (Some bal) wip } + let cf = toBalanceCarriedForward history + return! aux successorEpochId (Some cf) wip } aux member __.Execute(locationId, decide) = async { @@ -35,12 +37,12 @@ type Service internal (zeroBalance, shouldClose, series : Series.Service, epochs [] module Helpers = - let create (zeroBalance, shouldClose) (series, epochs) = - Service(zeroBalance, shouldClose, series, epochs) + let create (zeroBalance, toBalanceCarriedForward, shouldClose) (series, epochs) = + Service(zeroBalance, toBalanceCarriedForward, shouldClose, series, epochs) module Cosmos = - let createService (zeroBalance, shouldClose) (context, cache, maxAttempts) = + let createService (zeroBalance, toBalanceCarriedForward, shouldClose) (context, cache, maxAttempts) = let series = Series.Cosmos.createService (context, cache, maxAttempts) let epochs = Epoch.Cosmos.createService (context, cache, maxAttempts) - create (zeroBalance, shouldClose) (series, epochs) + create (zeroBalance, toBalanceCarriedForward, shouldClose) (series, epochs) diff --git a/equinox-fc/Domain/LocationEpoch.fs b/equinox-fc/Domain/LocationEpoch.fs index 421b7a052..91cec84df 100644 --- a/equinox-fc/Domain/LocationEpoch.fs +++ b/equinox-fc/Domain/LocationEpoch.fs @@ -10,71 +10,121 @@ module Events = let [] CategoryId = "LocationEpoch" let (|For|) (locationId, epochId) = FsCodec.StreamName.compose CategoryId [LocationId.toString locationId; LocationEpochId.toString epochId] - type CarriedForward = { initial : int } - type Delta = { delta : int; transaction : InventoryTransactionId } - type Value = { value : int; transaction : InventoryTransactionId } + type CarriedForward = { initial : int; recentTransactions : InventoryTransactionId[] } type Event = | CarriedForward of CarriedForward + | Added of {| delta : int; id : InventoryTransactionId |} + | Removed of {| delta : int; id : InventoryTransactionId |} + | Reset of {| value : int; id : InventoryTransactionId |} | Closed - | Added of Delta - | Removed of Delta - | Reset of Value interface TypeShape.UnionContract.IUnionContract let codec = FsCodec.NewtonsoftJson.Codec.Create() module Fold = - type Balance = int - type OpenState = { count : int; value : Balance } - type State = Initial | Open of OpenState | Closed of Balance + type State = + | Initial + | Open of Record list // reverse order, i.e. most revent first + | Closed of Record list // trimmed + and Record = + | Init of Events.CarriedForward + | Step of Step + and Step = { balance : Balance; id : InventoryTransactionId } + and Balance = int let initial = Initial + let (|Current|) = function + | (Init { initial = bal } | Step { balance = bal }) :: _ -> bal + | [] -> failwith "Cannot transact when no CarriedForward" let evolve state event = match event, state with - | Events.CarriedForward e, Initial -> Open { count = 0; value = e.initial } - | Events.Added e, Open bal -> Open { count = bal.count + 1; value = bal.value + e.delta } - | Events.Removed e, Open bal -> Open { count = bal.count + 1; value = bal.value - e.delta } - | Events.Reset e, Open bal -> Open { count = bal.count + 1; value = e.value } - | Events.Closed, Open { value = bal } -> Closed bal - | Events.CarriedForward _, (Open _|Closed _ as x) -> failwithf "CarriedForward : Unexpected %A" x - | (Events.Added _|Events.Removed _|Events.Reset _|Events.Closed) as e, (Initial|Closed _ as s) -> failwithf "Unexpected %A when %A" e s + | Events.CarriedForward e, Initial -> Open [Init e] + | Events.Added e, Open (Current cur as log) -> Open (Step { id = e.id ; balance = cur + e.delta } :: log) + | Events.Removed e, Open (Current cur as log) -> Open (Step { id = e.id ; balance = cur - e.delta } :: log) + | Events.Reset e, Open log -> Open (Step { id = e.id ; balance = e.value } :: log) + | Events.Closed, Open log -> Closed log + | Events.CarriedForward _, (Open _ | Closed _ as x) -> failwithf "CarriedForward : Unexpected %A" x + | (Events.Added _ | Events.Removed _ | Events.Reset _ | Events.Closed) as e, (Initial | Closed _ as s) -> + failwithf "Unexpected %A when %A" e s let fold = Seq.fold evolve /// Holds events accumulated from a series of decisions while also evolving the presented `state` to reflect the pended events type private Accumulator() = let acc = ResizeArray() member __.Ingest state : 'res * Events.Event list -> 'res * Fold.State = function - | res, [] -> res, state - | res, [e] -> acc.Add e; res, Fold.evolve state e - | res, xs -> acc.AddRange xs; res, Fold.fold state (Seq.ofList xs) + | res, [] -> res, state + | res, [e] -> acc.Add e; res, Fold.evolve state e + | res, xs -> acc.AddRange xs; res, Fold.fold state (Seq.ofList xs) member __.Accumulated = List.ofSeq acc -type Result<'t> = { balance : Fold.Balance; result : 't option; isOpen : bool } +type Result<'t> = { history : Fold.Record list; result : 't option; isOpen : bool } -let sync (balanceCarriedForward : Fold.Balance option) (decide : Fold.Balance -> 't*Events.Event list) shouldClose state : Result<'t>*Events.Event list = +let sync (carriedForward : Events.CarriedForward option) + (decide : Fold.State -> 't * Events.Event list) + shouldClose + state + : Result<'t> * Events.Event list = let acc = Accumulator() - // We require a CarriedForward event at the start of any Epoch's event stream + // 1. Guarantee a CarriedForward event at the start of any Epoch's event stream let (), state = acc.Ingest state <| match state with - | Fold.Initial -> (), [Events.CarriedForward { initial = Option.get balanceCarriedForward }] + | Fold.Initial -> (), [Events.CarriedForward (Option.get carriedForward )] | Fold.Open _ | Fold.Closed _ -> (), [] - // Run, unless we determine we're in Closed state + // 2. Transact (unless we determine we're in Closed state) let result, state = acc.Ingest state <| match state with - | Fold.Initial -> failwith "We've just guaranteed not Initial" - | Fold.Open { value = bal } -> let r, es = decide bal in Some r, es - | Fold.Closed _ -> None, [] - // Finally (iff we're `Open`, have run a `decide` and `shouldClose`), we generate a Closed event - let (balance, isOpen), _ = + | Fold.Initial -> failwith "We've just guaranteed not Initial" + | Fold.Open history -> let r, es = decide state in Some r, es + | Fold.Closed _ -> None, [] + // 3. Finally (iff we're `Open`, have run a `decide` and `shouldClose`), we generate a Closed event + let (history, isOpen), _ = acc.Ingest state <| match state with - | Fold.Initial -> failwith "Can't be Initial" - | Fold.Open ({ value = bal } as openState) when shouldClose openState -> (bal, false), [Events.Closed] - | Fold.Open { value = bal } -> (bal, true), [] - | Fold.Closed bal -> (bal, false), [] - { balance = balance; result = result; isOpen = isOpen }, acc.Accumulated + | Fold.Initial -> failwith "Can't be Initial" + | Fold.Open history -> + if shouldClose history then (history, false), [Events.Closed] + else (history, true), [] + | Fold.Closed history -> (history, false), [] + { history = history; result = result; isOpen = isOpen }, acc.Accumulated + +type DupCheckResult = NotDuplicate | IdempotentInsert of Fold.Balance | DupCarriedForward +let private tryFindDup transactionId (history : Fold.Record list) = + let tryMatch : Fold.Record -> Fold.Balance option option = function + | Fold.Step { balance = bal; id = id } when id = transactionId -> Some (Some bal) + | Fold.Init { recentTransactions = prevs } when prevs |> Array.contains transactionId -> Some None + | _ -> None + match history |> Seq.tryPick tryMatch with + | None -> NotDuplicate + | Some None -> DupCarriedForward + | Some (Some bal) -> IdempotentInsert bal + +type Command = + | Reset of value : int + | Add of delta : int + | Remove of delta : int + +type Result = Accepted of Fold.Balance | DupFromPreviousEpoch + +let decide transactionId command (state: Fold.State) = + match state with + | Fold.Closed _ | Fold.Initial -> failwithf "Cannot apply in state %A" state + | Fold.Open history -> + + match tryFindDup transactionId history with + | IdempotentInsert bal -> Accepted bal, [] + | DupCarriedForward -> DupFromPreviousEpoch, [] + | NotDuplicate -> + + let e = + match command with + | Reset value -> Events.Reset {| value = value; id = transactionId |} + | Add delta -> Events.Added {| delta = delta; id = transactionId |} + | Remove delta -> Events.Removed {| delta = delta; id = transactionId |} + match Fold.evolve state e with + | Fold.Open (Fold.Current cur) -> Accepted cur, [] + | s -> failwithf "Unexpected state %A" s type Service internal (log, resolve, maxAttempts) = From 577328b9169c746edaa689ddb604136db2c7a3d1 Mon Sep 17 00:00:00 2001 From: Ruben Bartelink Date: Fri, 21 Feb 2020 23:15:21 +0000 Subject: [PATCH 30/51] Minor renames; tests not yet compiling --- equinox-fc/Domain.Tests/LocationEpochTests.fs | 6 +++--- equinox-fc/Domain.Tests/LocationTests.fs | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/equinox-fc/Domain.Tests/LocationEpochTests.fs b/equinox-fc/Domain.Tests/LocationEpochTests.fs index b0c244314..4184d9d13 100644 --- a/equinox-fc/Domain.Tests/LocationEpochTests.fs +++ b/equinox-fc/Domain.Tests/LocationEpochTests.fs @@ -7,8 +7,8 @@ open Swensen.Unquote let interpret transactionId delta _balance = match delta with | 0 -> (), [] - | delta when delta < 0 -> (), [Events.Removed { delta = -delta; transaction = transactionId }] - | delta -> (), [Events.Added { delta = delta; transaction = transactionId }] + | delta when delta < 0 -> (), [Events.Removed {| delta = -delta; id = transactionId |}] + | delta -> (), [Events.Added {| delta = delta; id = transactionId |}] let validateAndInterpret transactionId expectedBalance delta balance = test <@ expectedBalance = balance @> @@ -28,7 +28,7 @@ let [] properties transactionId carriedForward delta1 closeImmediately let cfEvents events = events |> List.filter (function Events.CarriedForward _ -> true | _ -> false) let closeEvents events = events |> List.filter (function Events.Closed -> true | _ -> false) let state1 = Fold.fold Fold.initial events - let expectedBalance = carriedForward + delta1 + let expectedBalance = carriedForward.initial + delta1 // Only expect closing if it was requested let expectImmediateClose = closeImmediately test <@ Option.isSome res.result diff --git a/equinox-fc/Domain.Tests/LocationTests.fs b/equinox-fc/Domain.Tests/LocationTests.fs index 52e6b75f7..536505c57 100644 --- a/equinox-fc/Domain.Tests/LocationTests.fs +++ b/equinox-fc/Domain.Tests/LocationTests.fs @@ -38,8 +38,8 @@ let run (service : Location.Service) (IdsAtLeastOne locations, deltas : _[], tra let adjust delta (bal : Epoch.Fold.Balance) = let value = max -bal delta if value = 0 then 0, [] - elif value < 0 then value, [Epoch.Events.Removed { delta = -value; transaction = transactionId }] - else value, [Epoch.Events.Added { delta = value; transaction = transactionId }] + elif value < 0 then value, [Epoch.Events.Removed {| delta = -value; id = transactionId |}] + else value, [Epoch.Events.Added {| delta = value; id = transactionId |}] let! appliedDeltas = seq { for loc, x in updates -> async { let! _, eff = service.Execute(loc, adjust x) in return loc,eff } } |> Async.Parallel let expectedBalances = Seq.append (seq { for l in locations -> l, 0}) appliedDeltas |> Seq.groupBy fst |> Seq.map (fun (l, xs) -> l, xs |> Seq.sumBy snd) |> Set.ofSeq From bd6ba8ea88c103a598f56cf54ee6528f8948748a Mon Sep 17 00:00:00 2001 From: Ruben Bartelink Date: Sun, 23 Feb 2020 19:55:22 +0000 Subject: [PATCH 31/51] WIP --- dotnet-templates.sln | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/dotnet-templates.sln b/dotnet-templates.sln index 18f262e39..3e3c33b8d 100644 --- a/dotnet-templates.sln +++ b/dotnet-templates.sln @@ -187,6 +187,10 @@ Global {83BA87C3-6288-40F4-BC4F-EC3A54586CDF}.Debug|Any CPU.Build.0 = Debug|Any CPU {83BA87C3-6288-40F4-BC4F-EC3A54586CDF}.Release|Any CPU.ActiveCfg = Release|Any CPU {83BA87C3-6288-40F4-BC4F-EC3A54586CDF}.Release|Any CPU.Build.0 = Release|Any CPU + {8C92B728-85A5-4231-863A-E4236E46CC36}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8C92B728-85A5-4231-863A-E4236E46CC36}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8C92B728-85A5-4231-863A-E4236E46CC36}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8C92B728-85A5-4231-863A-E4236E46CC36}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {F66A5BFE-7C81-44DC-97DE-3FD8C83B8F06} = {B72FFAAE-7801-41B2-86F5-FD90E97A30F7} From 2ae86f2d30567ad01c411a0ba039fc0f1358ea81 Mon Sep 17 00:00:00 2001 From: Ruben Bartelink Date: Tue, 25 Feb 2020 01:55:45 +0000 Subject: [PATCH 32/51] Cover denial of Remove action --- equinox-fc/Domain/Inventory.fs | 29 +++++++++++++---------- equinox-fc/Domain/InventoryTransaction.fs | 5 ++-- equinox-fc/Domain/LocationEpoch.fs | 17 ++++++------- 3 files changed, 28 insertions(+), 23 deletions(-) diff --git a/equinox-fc/Domain/Inventory.fs b/equinox-fc/Domain/Inventory.fs index 80943ea48..3658b863d 100644 --- a/equinox-fc/Domain/Inventory.fs +++ b/equinox-fc/Domain/Inventory.fs @@ -94,26 +94,29 @@ module Processor = match action with | Transaction.Adjust (loc, bal) -> match! locations.Execute(loc, f (Fc.Location.Epoch.Reset bal)) with - | Fc.Location.Epoch.Accepted _ -> do! aux (Transaction.Events.Adjusted) - | Fc.Location.Epoch.DupFromPreviousEpoch -> failwith "TODO walk back to previous epoch" + | Fc.Location.Epoch.Accepted _ -> return! aux (Transaction.Events.Adjusted) + | Fc.Location.Epoch.Denied -> return failwith "Cannot Deny Reset" + | Fc.Location.Epoch.DupFromPreviousEpoch -> return failwith "TODO walk back to previous epoch" | Transaction.Remove (loc, delta) -> match! locations.Execute(loc, f (Fc.Location.Epoch.Remove delta)) with - | Fc.Location.Epoch.Accepted bal -> do! aux (Transaction.Events.Removed { balance = bal }) - | Fc.Location.Epoch.DupFromPreviousEpoch -> failwith "TODO walk back to previous epoch" + | Fc.Location.Epoch.Accepted bal -> return! aux (Transaction.Events.Removed { balance = bal }) + | Fc.Location.Epoch.Denied -> return! aux Transaction.Events.Failed + | Fc.Location.Epoch.DupFromPreviousEpoch -> return failwith "TODO walk back to previous epoch" | Transaction.Add (loc, delta) -> match! locations.Execute(loc, f (Fc.Location.Epoch.Add delta)) with - | Fc.Location.Epoch.Accepted bal -> do! aux (Transaction.Events.Added { balance = bal }) - | Fc.Location.Epoch.DupFromPreviousEpoch -> failwith "TODO walk back to previous epoch" - | Transaction.Log (Transaction.Adjusted e) -> + | Fc.Location.Epoch.Accepted bal -> return! aux (Transaction.Events.Added { balance = bal }) + | Fc.Location.Epoch.Denied -> return failwith "Cannot Deny Add" + | Fc.Location.Epoch.DupFromPreviousEpoch -> return failwith "TODO walk back to previous epoch" + | Transaction.Log (Transaction.Adjusted _) -> let! _count = inventory.Ingest([Fc.Inventory.Epoch.Events.Adjusted { transactionId = transactionId }]) - do! aux Transaction.Events.Logged - | Transaction.Log (Transaction.Transferred e) -> + return! aux Transaction.Events.Logged + | Transaction.Log (Transaction.Transferred _) -> let! _count = inventory.Ingest([Fc.Inventory.Epoch.Events.Transferred { transactionId = transactionId }]) - do! aux Transaction.Events.Logged - | Transaction.Finish -> - () + return! aux Transaction.Events.Logged + | Transaction.Finish success -> + return success } aux - member __.Apply(transactionId, update) = + member __.TryApply(transactionId, update) = execute transactionId update diff --git a/equinox-fc/Domain/InventoryTransaction.fs b/equinox-fc/Domain/InventoryTransaction.fs index 31c168ce7..40e76e8b4 100644 --- a/equinox-fc/Domain/InventoryTransaction.fs +++ b/equinox-fc/Domain/InventoryTransaction.fs @@ -43,7 +43,7 @@ type Action = | Remove of LocationId * int | Add of LocationId * int | Log of LoggingState - | Finish + | Finish of success : bool and LoggingState = | Adjusted of Events.AdjustmentRequested | Transferred of Added @@ -118,7 +118,8 @@ module Fold = | Running (Transfer (Requested r)) -> Action.Remove (r.source, r.quantity) | Running (Transfer (Adding r)) -> Action.Add (r.request.destination, r.request.quantity) | Logging s -> Action.Log s - | Completed _ -> Finish + | Completed (TransferFailed _) -> Finish false + | Completed (Transferred _ | Adjusted _) -> Finish true /// Given an event from the Process's timeline, yields the State, in order that it can be completed let decide update (state : Fold.State) : Action * Events.Event list = diff --git a/equinox-fc/Domain/LocationEpoch.fs b/equinox-fc/Domain/LocationEpoch.fs index 91cec84df..b5c32f86f 100644 --- a/equinox-fc/Domain/LocationEpoch.fs +++ b/equinox-fc/Domain/LocationEpoch.fs @@ -105,25 +105,26 @@ type Command = | Add of delta : int | Remove of delta : int -type Result = Accepted of Fold.Balance | DupFromPreviousEpoch +type Result = Denied | Accepted of Fold.Balance | DupFromPreviousEpoch let decide transactionId command (state: Fold.State) = match state with | Fold.Closed _ | Fold.Initial -> failwithf "Cannot apply in state %A" state - | Fold.Open history -> + | Fold.Open (Fold.Current cur as history) -> match tryFindDup transactionId history with | IdempotentInsert bal -> Accepted bal, [] | DupCarriedForward -> DupFromPreviousEpoch, [] | NotDuplicate -> - let e = + let accepted, events = match command with - | Reset value -> Events.Reset {| value = value; id = transactionId |} - | Add delta -> Events.Added {| delta = delta; id = transactionId |} - | Remove delta -> Events.Removed {| delta = delta; id = transactionId |} - match Fold.evolve state e with - | Fold.Open (Fold.Current cur) -> Accepted cur, [] + | Reset value -> true, [Events.Reset {| value = value; id = transactionId |}] + | Add delta -> true, [Events.Added {| delta = delta; id = transactionId |}] + | Remove delta when delta > cur -> false, [] + | Remove delta -> true, [Events.Removed {| delta = delta; id = transactionId |}] + match Fold.fold state events with + | Fold.Open (Fold.Current cur) -> (if accepted then Accepted cur else Denied), events | s -> failwithf "Unexpected state %A" s type Service internal (log, resolve, maxAttempts) = From 49ffb38dd832b9789278bf5b83fa17556e4161d8 Mon Sep 17 00:00:00 2001 From: Ruben Bartelink Date: Wed, 26 Feb 2020 09:59:05 +0000 Subject: [PATCH 33/51] Add explicit Service action methods on Process Manager --- equinox-fc/Domain/Inventory.fs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/equinox-fc/Domain/Inventory.fs b/equinox-fc/Domain/Inventory.fs index 3658b863d..d972eee93 100644 --- a/equinox-fc/Domain/Inventory.fs +++ b/equinox-fc/Domain/Inventory.fs @@ -118,5 +118,9 @@ module Processor = } aux - member __.TryApply(transactionId, update) = - execute transactionId update + member __.Adjust(transactionId, location, quantity) = + execute transactionId (Fc.Inventory.Transaction.Events.AdjustmentRequested { location = location; quantity = quantity }) + + member __.TryTransfer(transactionId, source, destination, quantity) = + execute transactionId (Fc.Inventory.Transaction.Events.TransferRequested { source = source; destination = destination; quantity = quantity }) + From 5dd465e34b597d76530c03b8047e5cbbcf676387 Mon Sep 17 00:00:00 2001 From: Ruben Bartelink Date: Wed, 26 Feb 2020 13:05:57 +0000 Subject: [PATCH 34/51] Add Watchdog --- dotnet-templates.sln | 7 + equinox-fc/Domain/Infrastructure.fs | 1 + equinox-fc/Domain/Inventory.fs | 11 +- equinox-fc/Domain/InventoryTransaction.fs | 41 ++- equinox-fc/Watchdog/Handler.fs | 54 +++ equinox-fc/Watchdog/Program.fs | 395 ++++++++++++++++++++++ equinox-fc/Watchdog/Watchdog.fsproj | 26 ++ 7 files changed, 531 insertions(+), 4 deletions(-) create mode 100644 equinox-fc/Watchdog/Handler.fs create mode 100644 equinox-fc/Watchdog/Program.fs create mode 100644 equinox-fc/Watchdog/Watchdog.fsproj diff --git a/dotnet-templates.sln b/dotnet-templates.sln index 3e3c33b8d..085f27078 100644 --- a/dotnet-templates.sln +++ b/dotnet-templates.sln @@ -107,6 +107,8 @@ Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Domain", "equinox-fc\Domain EndProject Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Domain.Tests", "equinox-fc\Domain.Tests\Domain.Tests.fsproj", "{49890A45-D6C2-4EF6-87AD-39960E03E254}" EndProject +Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Watchdog", "equinox-fc\Watchdog\Watchdog.fsproj", "{46B8B7C9-3334-4C13-A339-57571C14F2B9}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -191,6 +193,10 @@ Global {8C92B728-85A5-4231-863A-E4236E46CC36}.Debug|Any CPU.Build.0 = Debug|Any CPU {8C92B728-85A5-4231-863A-E4236E46CC36}.Release|Any CPU.ActiveCfg = Release|Any CPU {8C92B728-85A5-4231-863A-E4236E46CC36}.Release|Any CPU.Build.0 = Release|Any CPU + {46B8B7C9-3334-4C13-A339-57571C14F2B9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {46B8B7C9-3334-4C13-A339-57571C14F2B9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {46B8B7C9-3334-4C13-A339-57571C14F2B9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {46B8B7C9-3334-4C13-A339-57571C14F2B9}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {F66A5BFE-7C81-44DC-97DE-3FD8C83B8F06} = {B72FFAAE-7801-41B2-86F5-FD90E97A30F7} @@ -210,5 +216,6 @@ Global {9AFF6138-B63B-4EBF-B86B-4F626E1F1ADF} = {DAE9E2B9-EDA2-4064-B0CE-FD5294549374} {5A45EF21-576B-4B40-86BD-F5960ECD66BF} = {DAE9E2B9-EDA2-4064-B0CE-FD5294549374} {83BA87C3-6288-40F4-BC4F-EC3A54586CDF} = {DAE9E2B9-EDA2-4064-B0CE-FD5294549374} + {46B8B7C9-3334-4C13-A339-57571C14F2B9} = {4946576F-1558-49ED-A272-6F4D92FB0031} EndGlobalSection EndGlobal diff --git a/equinox-fc/Domain/Infrastructure.fs b/equinox-fc/Domain/Infrastructure.fs index 02863a4a3..2f41917e5 100644 --- a/equinox-fc/Domain/Infrastructure.fs +++ b/equinox-fc/Domain/Infrastructure.fs @@ -32,4 +32,5 @@ type InventoryTransactionId = string and [] inventoryTransactionId module InventoryTransactionId = let parse (value : string) : InventoryTransactionId = %value + let (|Parse|) = parse let toString (value : InventoryTransactionId) : string = %value diff --git a/equinox-fc/Domain/Inventory.fs b/equinox-fc/Domain/Inventory.fs index d972eee93..14754695f 100644 --- a/equinox-fc/Domain/Inventory.fs +++ b/equinox-fc/Domain/Inventory.fs @@ -91,10 +91,11 @@ module Processor = let f = Fc.Location.Epoch.decide transactionId let rec aux update = async { let! action = transactions.Apply(transactionId, update) + let aux event = aux (Some event) match action with | Transaction.Adjust (loc, bal) -> match! locations.Execute(loc, f (Fc.Location.Epoch.Reset bal)) with - | Fc.Location.Epoch.Accepted _ -> return! aux (Transaction.Events.Adjusted) + | Fc.Location.Epoch.Accepted _ -> return! aux Transaction.Events.Adjusted | Fc.Location.Epoch.Denied -> return failwith "Cannot Deny Reset" | Fc.Location.Epoch.DupFromPreviousEpoch -> return failwith "TODO walk back to previous epoch" | Transaction.Remove (loc, delta) -> @@ -117,10 +118,14 @@ module Processor = return success } aux + let run transactionId req = execute transactionId (Some req) member __.Adjust(transactionId, location, quantity) = - execute transactionId (Fc.Inventory.Transaction.Events.AdjustmentRequested { location = location; quantity = quantity }) + run transactionId (Fc.Inventory.Transaction.Events.AdjustmentRequested { location = location; quantity = quantity }) member __.TryTransfer(transactionId, source, destination, quantity) = - execute transactionId (Fc.Inventory.Transaction.Events.TransferRequested { source = source; destination = destination; quantity = quantity }) + run transactionId (Fc.Inventory.Transaction.Events.TransferRequested { source = source; destination = destination; quantity = quantity }) + /// Used by Watchdog to force conclusion of a transaction whose progress has stalled + member __.Push(transactionId) = async { + let! _ = execute transactionId None in () } \ No newline at end of file diff --git a/equinox-fc/Domain/InventoryTransaction.fs b/equinox-fc/Domain/InventoryTransaction.fs index 40e76e8b4..9ee61b472 100644 --- a/equinox-fc/Domain/InventoryTransaction.fs +++ b/equinox-fc/Domain/InventoryTransaction.fs @@ -123,7 +123,10 @@ module Fold = /// Given an event from the Process's timeline, yields the State, in order that it can be completed let decide update (state : Fold.State) : Action * Events.Event list = - let events = Fold.filter update state + let events = + match update with + | None -> [] + | Some update -> Fold.filter update state let state' = Fold.fold state events Fold.nextAction state', events @@ -149,3 +152,39 @@ module Cosmos = let cacheStrategy = CachingStrategy.SlidingWindow (cache, System.TimeSpan.FromMinutes 20.) fun id -> Resolver(context, Events.codec, Fold.fold, Fold.initial, cacheStrategy, accessStrategy).Resolve(id, opt) let createService (context, cache) = createService (resolve (context, cache)) + +/// Handles requirement to infer when a transaction is 'stuck' +/// Note we don't want to couple to the state in a deep manner; thus we track: +/// a) when the request intent is established (aka when *Requested is logged) +/// b) when a transaction reports that it has Completed +module Watchdog = + + module Events = + + // TOCONSIDER: this can be written more generically by just grabbing the time of the very first event + type Event = + | AdjustmentRequested + | TransferRequested + | Completed + interface TypeShape.UnionContract.IUnionContract + type TimestampAndEvent = System.DateTimeOffset * Event + let codec = + let up (encoded : FsCodec.ITimelineEvent<_>, message) : TimestampAndEvent = encoded.Timestamp, message + let down _union = failwith "Not Implemented" + FsCodec.NewtonsoftJson.Codec.Create(up, down) + + module Fold = + + type State = Initial | Active of startTime: System.DateTimeOffset | Completed + let initial = Initial + let evolve _state = function + | startTime, (Events.AdjustmentRequested | Events.TransferRequested ) -> Active startTime + | _, Events.Completed -> Completed + let fold : State -> Events.TimestampAndEvent seq -> State = Seq.fold evolve + + type Status = Complete | Active | Stuck + let categorize cutoffTime = function + | Fold.Initial -> Active // cover (hypothetical) corner case where we don't have any valid events yet + | Fold.Active startTime when startTime < cutoffTime -> Stuck + | Fold.Active _ -> Active + | Fold.Completed -> Complete diff --git a/equinox-fc/Watchdog/Handler.fs b/equinox-fc/Watchdog/Handler.fs new file mode 100644 index 000000000..908923a04 --- /dev/null +++ b/equinox-fc/Watchdog/Handler.fs @@ -0,0 +1,54 @@ +module Fc.Watchdog.Handler + +open System + +[] +type Outcome = Completed | Deferred | Nudged + +type Stats(log, ?statsInterval, ?stateInterval) = + inherit Propulsion.Streams.Projector.Stats(log, defaultArg statsInterval (TimeSpan.FromMinutes 1.), defaultArg stateInterval (TimeSpan.FromMinutes 5.)) + + let mutable completed, deferred, resolved = 0, 0, 0 + + override __.HandleOk res = res |> function + | Outcome.Completed -> completed <- completed + 1 + | Outcome.Deferred -> deferred <- deferred + 1 + | Outcome.Nudged -> resolved <- resolved + 1 + + override __.DumpStats () = + if completed <> 0 || deferred <> 0 then + log.Information(" Completed {completed} Deferred {deferred} Resolved {resolved}", completed, deferred, resolved) + completed <- 0; deferred <- 0; resolved <- 0 + +open Fc.Inventory.Transaction + +let fold : Watchdog.Events.TimestampAndEvent seq -> Watchdog.Fold.State = + Watchdog.Fold.fold Watchdog.Fold.initial + +let (|FoldToWatchdogState|) (span : Propulsion.Streams.StreamSpan<_>) : Watchdog.Fold.State = + span.events + |> Seq.choose Watchdog.Events.codec.TryDecode + |> fold + +let tryHandle driveTransaction (stream, span : Propulsion.Streams.StreamSpan<_>) : Async = async { + let processingStuckCutoff = let now = DateTimeOffset.UtcNow in now.AddSeconds -10. + match stream, span with + | FsCodec.StreamName.CategoryAndId (Events.CategoryId, InventoryTransactionId.Parse transId), FoldToWatchdogState state -> + match Watchdog.categorize processingStuckCutoff state with + | Watchdog.Complete -> + return Outcome.Completed + | Watchdog.Active -> + // Visiting every second is not too expensive; we don't want to be warming the data center for no purpose + do! Async.Sleep 1000 // ms + return Outcome.Deferred + | Watchdog.Stuck -> + do! driveTransaction transId + return Outcome.Nudged + | _ -> return failwith "Not a relevant event" } + +let handleStreamEvents tryHandle (stream, span : Propulsion.Streams.StreamSpan<_>) : Async = async { + match! tryHandle (stream, span) with + // if we're deferring, we need to retain the events (all of them, as the time of the first one is salient) + | Outcome.Deferred as r -> return span.index, r + // if it's now complete (either organically, or with our help), we can release all the events pertaining to this transaction + | Outcome.Completed | Outcome.Nudged as r -> return span.index + span.events.LongLength, r } diff --git a/equinox-fc/Watchdog/Program.fs b/equinox-fc/Watchdog/Program.fs new file mode 100644 index 000000000..5d7cc4219 --- /dev/null +++ b/equinox-fc/Watchdog/Program.fs @@ -0,0 +1,395 @@ +module Fc.Watchdog.Program + +open Propulsion.Cosmos +open Propulsion.EventStore +open Serilog +open System + +module EnvVar = + + let tryGet varName : string option = Environment.GetEnvironmentVariable varName |> Option.ofObj + let set varName value : unit = Environment.SetEnvironmentVariable(varName, value) + +module Settings = + + let private initEnvVar var key loadF = + if None = EnvVar.tryGet var then + printfn "Setting %s from %A" var key + EnvVar.set var (loadF key) + + let initialize () = + // e.g. initEnvVar "EQUINOX_COSMOS_COLLECTION" "CONSUL KEY" readFromConsul + () // TODO add any custom logic preprocessing commandline arguments and/or gathering custom defaults from external sources, etc + +module CmdParser = + + exception MissingArg of string + let private getEnvVarForArgumentOrThrow varName argName = + match EnvVar.tryGet varName with + | None -> raise (MissingArg(sprintf "Please provide a %s, either as an argument or via the %s environment variable" argName varName)) + | Some x -> x + let private defaultWithEnvVar varName argName = function + | None -> getEnvVarForArgumentOrThrow varName argName + | Some x -> x + open Argu + open Equinox.Cosmos + open Equinox.EventStore + [] + type Parameters = + | [] ConsumerGroupName of string + | [] MaxReadAhead of int + | [] MaxWriters of int + | [] Verbose + | [] VerboseConsole + + + | [] Es of ParseResults + | [] SrcCosmos of ParseResults + interface IArgParserTemplate with + member a.Usage = + match a with + | ConsumerGroupName _ -> "Projector consumer group name." + | MaxReadAhead _ -> "maximum number of batches to let processing get ahead of completion. Default: 16." + | MaxWriters _ -> "maximum number of concurrent streams on which to process at any time. Default: 64." + | Verbose -> "request Verbose Logging. Default: off." + | VerboseConsole -> "request Verbose Console Logging. Default: off." + | Es _ -> "specify EventStore input parameters." + | SrcCosmos _ -> "specify CosmosDB input parameters." + and Arguments(a : ParseResults) = + member __.ConsumerGroupName = a.GetResult ConsumerGroupName + member __.Verbose = a.Contains Parameters.Verbose + member __.VerboseConsole = a.Contains VerboseConsole + member __.MaxReadAhead = a.GetResult(MaxReadAhead, 16) + member __.MaxConcurrentStreams = a.GetResult(MaxWriters, 64) + member __.StatsInterval = TimeSpan.FromMinutes 1. + + member val Source : Choice = + match a.TryGetSubCommand() with + | Some (Es es) -> Choice1Of2 (EsSourceArguments es) + | Some (SrcCosmos cosmos) -> Choice2Of2 (CosmosSourceArguments cosmos) + | _ -> raise (MissingArg "Must specify one of cosmos or es for Src") + member x.SourceParams() : Choice = + match x.Source with + | Choice1Of2 srcE -> + let startPos, cosmos = srcE.StartPos, srcE.Cosmos + Log.Information("Processing Consumer Group {groupName} from {startPos} (force: {forceRestart}) in Database {db} Container {container}", + x.ConsumerGroupName, startPos, srcE.ForceRestart, cosmos.Database, cosmos.Container) + Log.Information("Ingesting in batches of [{minBatchSize}..{batchSize}], reading up to {maxReadAhead} uncommitted batches ahead", + srcE.MinBatchSize, srcE.StartingBatchSize, x.MaxReadAhead) + Choice1Of2 (srcE, cosmos, + { groupName = x.ConsumerGroupName; start = startPos; checkpointInterval = srcE.CheckpointInterval; tailInterval = srcE.TailInterval + forceRestart = srcE.ForceRestart + batchSize = srcE.StartingBatchSize; minBatchSize = srcE.MinBatchSize; gorge = srcE.Gorge; streamReaders = 0 }) + | Choice2Of2 srcC -> + let disco, auxColl = + match srcC.LeaseContainer with + | None -> srcC.Discovery, { database = srcC.Database; container = srcC.Container + "-aux" } + | Some sc -> srcC.Discovery, { database = srcC.Database; container = sc } + Log.Information("Max read backlog: {maxReadAhead}", x.MaxReadAhead) + Log.Information("Processing Lease {leaseId} in Database {db} Container {container} with maximum document count limited to {maxDocuments}", + x.ConsumerGroupName, auxColl.database, auxColl.container, srcC.MaxDocuments) + if srcC.FromTail then Log.Warning("(If new projector group) Skipping projection of all existing events.") + srcC.LagFrequency |> Option.iter (fun s -> Log.Information("Dumping lag stats at {lagS:n0}s intervals", s.TotalSeconds)) + Choice2Of2 (srcC, (disco, auxColl, x.ConsumerGroupName, srcC.FromTail, srcC.MaxDocuments, srcC.LagFrequency)) + and [] CosmosSourceParameters = + | [] FromTail + | [] MaxDocuments of int + | [] LagFreqM of float + | [] LeaseContainer of string + + | [] ConnectionMode of Equinox.Cosmos.ConnectionMode + | [] Connection of string + | [] Database of string + | [] Container of string // Actually Mandatory, but stating that is not supported + | [] Timeout of float + | [] Retries of int + | [] RetriesWaitTime of float + + | [] Cosmos of ParseResults + interface IArgParserTemplate with + member a.Usage = a |> function + | FromTail -> "(iff the Consumer Name is fresh) - force skip to present Position. Default: Never skip an event." + | MaxDocuments _ -> "maximum item count to request from feed. Default: unlimited" + | LagFreqM _ -> "frequency (in minutes) to dump lag stats. Default: off" + | LeaseContainer _ -> "specify Container Name for Leases container. Default: `sourceContainer` + `-aux`." + + | ConnectionMode _ -> "override the connection mode. Default: Direct." + | Connection _ -> "specify a connection string for a Cosmos account. (optional if environment variable EQUINOX_COSMOS_CONNECTION specified)" + | Database _ -> "specify a database name for Cosmos account. (optional if environment variable EQUINOX_COSMOS_DATABASE specified)" + | Container _ -> "specify a container name within `Database`" + | Timeout _ -> "specify operation timeout in seconds. Default: 5." + | Retries _ -> "specify operation retries. Default: 1." + | RetriesWaitTime _ -> "specify max wait-time for retry when being throttled by Cosmos in seconds. Default: 5." + + | Cosmos _ -> "CosmosDb Sink parameters." + and CosmosSourceArguments(a : ParseResults) = + member __.FromTail = a.Contains CosmosSourceParameters.FromTail + member __.MaxDocuments = a.TryGetResult MaxDocuments + member __.LagFrequency = a.TryGetResult LagFreqM |> Option.map TimeSpan.FromMinutes + member __.LeaseContainer = a.TryGetResult CosmosSourceParameters.LeaseContainer + + member __.Mode = a.GetResult(CosmosSourceParameters.ConnectionMode, Equinox.Cosmos.ConnectionMode.Direct) + member __.Discovery = Discovery.FromConnectionString __.Connection + member __.Connection = a.TryGetResult CosmosSourceParameters.Connection |> defaultWithEnvVar "EQUINOX_COSMOS_CONNECTION" "Connection" + member __.Database = a.TryGetResult CosmosSourceParameters.Database |> defaultWithEnvVar "EQUINOX_COSMOS_DATABASE" "Database" + member __.Container = a.GetResult CosmosSourceParameters.Container + member __.Timeout = a.GetResult(CosmosSourceParameters.Timeout, 5.) |> TimeSpan.FromSeconds + member __.Retries = a.GetResult(CosmosSourceParameters.Retries, 1) + member __.MaxRetryWaitTime = a.GetResult(CosmosSourceParameters.RetriesWaitTime, 5.) |> TimeSpan.FromSeconds + member x.BuildConnectionDetails() = + let (Discovery.UriAndKey (endpointUri, _)) as discovery = x.Discovery + Log.Information("Source CosmosDb {mode} {endpointUri} Database {database} Container {container}", + x.Mode, endpointUri, x.Database, x.Container) + Log.Information("Source CosmosDb timeout {timeout}s; Throttling retries {retries}, max wait {maxRetryWaitTime}s", + (let t = x.Timeout in t.TotalSeconds), x.Retries, (let t = x.MaxRetryWaitTime in t.TotalSeconds)) + let c = Equinox.Cosmos.Connector(x.Timeout, x.Retries, x.MaxRetryWaitTime, Log.Logger, mode=x.Mode) + discovery, { database = x.Database; container = x.Container }, c + + member val Cosmos = + match a.TryGetSubCommand() with + | Some (CosmosSourceParameters.Cosmos cosmos) -> CosmosArguments cosmos + | _ -> raise (MissingArg "Must specify cosmos details") + and [] EsSourceParameters = + | [] FromTail + | [] Gorge of int + | [] Tail of intervalS: float + | [] ForceRestart + | [] BatchSize of int + | [] MinBatchSize of int + | [] Position of int64 + | [] Chunk of int + | [] Percent of float + + | [] Verbose + | [] Timeout of float + | [] Retries of int + | [] HeartbeatTimeout of float + | [] Tcp + | [] Host of string + | [] Port of int + | [] Username of string + | [] Password of string + + | [] Cosmos of ParseResults + interface IArgParserTemplate with + member a.Usage = a |> function + | FromTail -> "Start the processing from the Tail" + | Gorge _ -> "Request Parallel readers phase during initial catchup, running one chunk (256MB) apart. Default: off" + | Tail _ -> "attempt to read from tail at specified interval in Seconds. Default: 1" + | ForceRestart _ -> "Forget the current committed position; start from (and commit) specified position. Default: start from specified position or resume from committed." + | BatchSize _ -> "maximum item count to request from feed. Default: 4096" + | MinBatchSize _ -> "minimum item count to drop down to in reaction to read failures. Default: 512" + | Position _ -> "EventStore $all Stream Position to commence from" + | Chunk _ -> "EventStore $all Chunk to commence from" + | Percent _ -> "EventStore $all Stream Position to commence from (as a percentage of current tail position)" + + | Verbose -> "Include low level Store logging." + | Tcp -> "Request connecting direct to a TCP/IP endpoint. Default: Use Clustered mode with Gossip-driven discovery (unless environment variable EQUINOX_ES_TCP specifies 'true')." + | Host _ -> "TCP mode: specify a hostname to connect to directly. Clustered mode: use Gossip protocol against all A records returned from DNS query. (optional if environment variable EQUINOX_ES_HOST specified)" + | Port _ -> "specify a custom port. Uses value of environment variable EQUINOX_ES_PORT if specified. Defaults for Cluster and Direct TCP/IP mode are 30778 and 1113 respectively." + | Username _ -> "specify a username. (optional if environment variable EQUINOX_ES_USERNAME specified)" + | Password _ -> "specify a Password. (optional if environment variable EQUINOX_ES_PASSWORD specified)" + | Timeout _ -> "specify operation timeout in seconds. Default: 20." + | Retries _ -> "specify operation retries. Default: 3." + | HeartbeatTimeout _ -> "specify heartbeat timeout in seconds. Default: 1.5." + + | Cosmos _ -> "CosmosDB (Checkpoint) Store parameters." + and EsSourceArguments(a : ParseResults) = + member __.Gorge = a.TryGetResult Gorge + member __.TailInterval = a.GetResult(Tail, 1.) |> TimeSpan.FromSeconds + member __.ForceRestart = a.Contains ForceRestart + member __.StartingBatchSize = a.GetResult(BatchSize, 4096) + member __.MinBatchSize = a.GetResult(MinBatchSize, 512) + member __.StartPos = + match a.TryGetResult Position, a.TryGetResult Chunk, a.TryGetResult Percent, a.Contains EsSourceParameters.FromTail with + | Some p, _, _, _ -> Absolute p + | _, Some c, _, _ -> StartPos.Chunk c + | _, _, Some p, _ -> Percentage p + | None, None, None, true -> StartPos.TailOrCheckpoint + | None, None, None, _ -> StartPos.StartOrCheckpoint + + member __.Discovery = + match __.Tcp, __.Port with + | false, None -> Discovery.GossipDns __.Host + | false, Some p -> Discovery.GossipDnsCustomPort (__.Host, p) + | true, None -> Discovery.Uri (UriBuilder("tcp", __.Host).Uri) + | true, Some p -> Discovery.Uri (UriBuilder("tcp", __.Host, p).Uri) + member __.Tcp = + a.Contains EsSourceParameters.Tcp + || EnvVar.tryGet "EQUINOX_ES_TCP" |> Option.exists (fun s -> String.Equals(s, bool.TrueString, StringComparison.OrdinalIgnoreCase)) + member __.Port = match a.TryGetResult Port with Some x -> Some x | None -> EnvVar.tryGet "EQUINOX_ES_PORT" |> Option.map int + member __.Host = a.TryGetResult Host |> defaultWithEnvVar "EQUINOX_ES_HOST" "Host" + member __.User = a.TryGetResult Username |> defaultWithEnvVar "EQUINOX_ES_USERNAME" "Username" + member __.Password = a.TryGetResult Password |> defaultWithEnvVar "EQUINOX_ES_PASSWORD" "Password" + member __.Retries = a.GetResult(EsSourceParameters.Retries, 3) + member __.Timeout = a.GetResult(EsSourceParameters.Timeout, 20.) |> TimeSpan.FromSeconds + member __.Heartbeat = a.GetResult(HeartbeatTimeout, 1.5) |> TimeSpan.FromSeconds + member x.Connect(log: ILogger, storeLog: ILogger, appName, connectionStrategy) = + let s (x : TimeSpan) = x.TotalSeconds + let discovery = x.Discovery + log.ForContext("host", x.Host).ForContext("port", x.Port) + .Information("EventStore {discovery} heartbeat: {heartbeat}s Timeout: {timeout}s Retries {retries}", + discovery, s x.Heartbeat, s x.Timeout, x.Retries) + let log=if storeLog.IsEnabled Serilog.Events.LogEventLevel.Debug then Logger.SerilogVerbose storeLog else Logger.SerilogNormal storeLog + let tags=["M", Environment.MachineName; "I", Guid.NewGuid() |> string] + Connector(x.User, x.Password, x.Timeout, x.Retries, log=log, heartbeatTimeout=x.Heartbeat, tags=tags) + .Establish(appName, discovery, connectionStrategy) |> Async.RunSynchronously + + member __.CheckpointInterval = TimeSpan.FromHours 1. + member val Cosmos : CosmosArguments = + match a.TryGetSubCommand() with + | Some (EsSourceParameters.Cosmos cosmos) -> CosmosArguments cosmos + | _ -> raise (MissingArg "Must specify `cosmos` checkpoint store when source is `es`") + and [] CosmosParameters = + | [] Connection of string + | [] ConnectionMode of ConnectionMode + | [] Database of string + | [] Container of string + | [] Timeout of float + | [] Retries of int + | [] RetriesWaitTime of float + interface IArgParserTemplate with + member a.Usage = + match a with + | ConnectionMode _ -> "override the connection mode. Default: Direct." + | Connection _ -> "specify a connection string for a Cosmos account. (optional if environment variable EQUINOX_COSMOS_CONNECTION specified)" + | Database _ -> "specify a database name for Cosmos store. (optional if environment variable EQUINOX_COSMOS_DATABASE specified)" + | Container _ -> "specify a container name for Cosmos store. (optional if environment variable EQUINOX_COSMOS_CONTAINER specified)" + | Timeout _ -> "specify operation timeout in seconds. Default: 5." + | Retries _ -> "specify operation retries. Default: 1." + | RetriesWaitTime _ -> "specify max wait-time for retry when being throttled by Cosmos in seconds. Default: 5." + and CosmosArguments(a : ParseResults) = + member __.Mode = a.GetResult(CosmosParameters.ConnectionMode, Equinox.Cosmos.ConnectionMode.Direct) + member __.Connection = a.TryGetResult CosmosParameters.Connection |> defaultWithEnvVar "EQUINOX_COSMOS_CONNECTION" "Connection" + member __.Database = a.TryGetResult CosmosParameters.Database |> defaultWithEnvVar "EQUINOX_COSMOS_DATABASE" "Database" + member __.Container = a.TryGetResult CosmosParameters.Container |> defaultWithEnvVar "EQUINOX_COSMOS_CONTAINER" "Container" + member __.Timeout = a.GetResult(CosmosParameters.Timeout, 5.) |> TimeSpan.FromSeconds + member __.Retries = a.GetResult(CosmosParameters.Retries, 1) + member __.MaxRetryWaitTime = a.GetResult(CosmosParameters.RetriesWaitTime, 5.) |> TimeSpan.FromSeconds + member x.BuildConnectionDetails() = + let (Discovery.UriAndKey (endpointUri, _) as discovery) = Discovery.FromConnectionString x.Connection + Log.Information("CosmosDb {mode} {endpointUri} Database {database} Container {container}", + x.Mode, endpointUri, x.Database, x.Container) + Log.Information("CosmosDb timeout {timeout}s; Throttling retries {retries}, max wait {maxRetryWaitTime}s", + (let t = x.Timeout in t.TotalSeconds), x.Retries, (let t = x.MaxRetryWaitTime in t.TotalSeconds)) + let connector = Equinox.Cosmos.Connector(x.Timeout, x.Retries, x.MaxRetryWaitTime, Log.Logger, mode=x.Mode) + discovery, x.Database, x.Container, connector + + /// Parse the commandline; can throw exceptions in response to missing arguments and/or `-h`/`--help` args + let parse argv = + let programName = System.Reflection.Assembly.GetEntryAssembly().GetName().Name + let parser = ArgumentParser.Create(programName = programName) + parser.ParseCommandLine argv |> Arguments + +module Logging = + + let initialize verbose changeLogVerbose = + Log.Logger <- + LoggerConfiguration() + .Destructure.FSharpTypes() + .Enrich.FromLogContext() + |> fun c -> if verbose then c.MinimumLevel.Debug() else c + // LibLog writes to the global logger, so we need to control the emission + |> fun c -> let cfpl = if changeLogVerbose then Serilog.Events.LogEventLevel.Debug else Serilog.Events.LogEventLevel.Warning + c.MinimumLevel.Override("Microsoft.Azure.Documents.ChangeFeedProcessor", cfpl) + |> fun c -> let isCfp429a = Filters.Matching.FromSource("Microsoft.Azure.Documents.ChangeFeedProcessor.LeaseManagement.DocumentServiceLeaseUpdater").Invoke + let isCfp429b = Filters.Matching.FromSource("Microsoft.Azure.Documents.ChangeFeedProcessor.PartitionManagement.LeaseRenewer").Invoke + let isCfp429c = Filters.Matching.FromSource("Microsoft.Azure.Documents.ChangeFeedProcessor.PartitionManagement.PartitionLoadBalancer").Invoke + let isCfp429d = Filters.Matching.FromSource("Microsoft.Azure.Documents.ChangeFeedProcessor.FeedProcessing.PartitionProcessor").Invoke + let isCfp x = isCfp429a x || isCfp429b x || isCfp429c x || isCfp429d x + if changeLogVerbose then c else c.Filter.ByExcluding(fun x -> isCfp x) + |> fun c -> let t = "[{Timestamp:HH:mm:ss} {Level:u3}] {partitionKeyRangeId,2} {Message:lj} {NewLine}{Exception}" + c.WriteTo.Console(theme=Sinks.SystemConsole.Themes.AnsiConsoleTheme.Code, outputTemplate=t) + |> fun c -> c.CreateLogger() + +let [] AppName = "Watchdog" + +module EventStoreContext = + let cache = Equinox.Cache(AppName, sizeMb = 10) + let create connection = Equinox.EventStore.Context(connection, Equinox.EventStore.BatchingPolicy(maxBatchSize=500)) + let tryMapEvent isWhitelisted (x : EventStore.ClientAPI.ResolvedEvent) = + match x.Event with + | e when not e.IsJson || e.EventStreamId.StartsWith "$" || not (isWhitelisted e.EventStreamId) -> None + | PropulsionStreamEvent e -> Some e + +let transactionStreamPrefix = sprintf "%s-" Fc.Inventory.Transaction.Events.CategoryId +let isTransactionStream : string -> bool = function sn -> sn.StartsWith transactionStreamPrefix + +let build (args : CmdParser.Arguments) = + match args.SourceParams() with + | Choice1Of2 (srcE, cosmos, spec) -> + let connectEs () = srcE.Connect(Log.Logger, Log.Logger, AppName, Equinox.EventStore.ConnectionStrategy.ClusterSingle Equinox.EventStore.NodePreference.PreferSlave) + let (discovery, database, container, connector) = cosmos.BuildConnectionDetails() + + let connection = connector.Connect(AppName, discovery) |> Async.RunSynchronously + let cache = Equinox.Cache(AppName, sizeMb = 10) + let context = Equinox.Cosmos.Context(connection, database, container) + + let resolveCheckpointStream = + let codec = FsCodec.NewtonsoftJson.Codec.Create() + let caching = Equinox.Cosmos.CachingStrategy.SlidingWindow (cache, TimeSpan.FromMinutes 20.) + let access = Equinox.Cosmos.AccessStrategy.Custom (Checkpoint.Fold.isOrigin, Checkpoint.Fold.transmute) + fun target -> Equinox.Cosmos.Resolver(context, codec, Checkpoint.Fold.fold, Checkpoint.Fold.initial, caching, access).Resolve(target, Equinox.AllowStale) + let checkpoints = Checkpoint.CheckpointSeries(spec.groupName, Log.ForContext(), resolveCheckpointStream) + let inventoryService = + let inventoryId = InventoryId.parse "FC000" + let maxTransactionsPerEpoch = 100 + let lookBackLimit = 10 + Fc.Inventory.Cosmos.create inventoryId maxTransactionsPerEpoch lookBackLimit (context, cache) + let transactionService = Fc.Inventory.Transaction.Cosmos.createService (context, cache) + let locations = + let zeroBalance : Fc.Location.Epoch.Events.CarriedForward = { initial = 0; recentTransactions = [||] } + let chooseTransactionIds = function + | Fc.Location.Epoch.Fold.Init { recentTransactions = ids } -> Seq.ofArray ids + | Fc.Location.Epoch.Fold.Step { id = id } -> Seq.singleton id + let toBalanceCarriedForward (Fc.Location.Epoch.Fold.Current cur as records) : Fc.Location.Epoch.Events.CarriedForward = + { initial = cur; recentTransactions = records |> Seq.collect chooseTransactionIds |> Seq.truncate 5 |> Seq.toArray } + let shouldClose x = false + let maxAttempts = 3 + Fc.Location.Cosmos.createService (zeroBalance, toBalanceCarriedForward, shouldClose) (context, cache, maxAttempts) + let processor = Fc.Inventory.Processor.Service(transactionService, locations, inventoryService) + + let handle = Handler.handleStreamEvents (Handler.tryHandle processor.Push) + let sink = + Propulsion.Streams.StreamsProjector.Start( + Log.Logger, args.MaxReadAhead, args.MaxConcurrentStreams, handle, stats = Handler.Stats(Log.Logger)) + let connect () = let c = connectEs () in c.ReadConnection + let runPipeline = + EventStoreSource.Run( + Log.Logger, sink, checkpoints, connect, spec, EventStoreContext.tryMapEvent isTransactionStream, + args.MaxReadAhead, args.StatsInterval) + sink, runPipeline + | Choice2Of2 (srcCosmos, (auxDiscovery, aux, leaseId, startFromTail, maxDocuments, lagFrequency)) -> + let (discovery, database, container, connector) = srcCosmos.Cosmos.BuildConnectionDetails() + let handle (_stream, _span) : Async = failwith "TODO" // Handler.handleStreamEvents (Handler.tryHandle driveTransaction) + let sink = + Propulsion.Streams.StreamsProjector.Start( + Log.Logger, args.MaxReadAhead, args.MaxConcurrentStreams, handle, stats = Handler.Stats(Log.Logger)) + let mapToStreamItems (docs : Microsoft.Azure.Documents.Document seq) : Propulsion.Streams.StreamEvent<_> seq = + docs + |> Seq.collect EquinoxCosmosParser.enumStreamEvents + |> Seq.filter (fun e -> e.stream |> FsCodec.StreamName.toString |> isTransactionStream) + let source = { database = database; container = container } + let createObserver () = CosmosSource.CreateObserver(Log.Logger, sink.StartIngester, mapToStreamItems) + let runPipeline = + CosmosSource.Run(Log.Logger, connector.CreateClient(AppName, discovery), source, aux, + leaseId, startFromTail, createObserver, + ?maxDocuments=maxDocuments, ?lagReportFreq=lagFrequency, auxClient=connector.CreateClient(AppName, auxDiscovery)) + sink, runPipeline + +let run argv = + try let args = CmdParser.parse argv + Logging.initialize args.Verbose args.VerboseConsole + Settings.initialize () + let projector, runSourcePipeline = build args + runSourcePipeline |> Async.Start + projector.AwaitCompletion() |> Async.RunSynchronously + if projector.RanToCompletion then 0 else 2 + with :? Argu.ArguParseException as e -> eprintfn "%s" e.Message; 1 + | CmdParser.MissingArg msg -> eprintfn "%s" msg; 1 + | e -> Log.Fatal(e, "Exiting"); 1 + +[] +let main argv = + try run argv + finally Log.CloseAndFlush() diff --git a/equinox-fc/Watchdog/Watchdog.fsproj b/equinox-fc/Watchdog/Watchdog.fsproj new file mode 100644 index 000000000..ebc91054c --- /dev/null +++ b/equinox-fc/Watchdog/Watchdog.fsproj @@ -0,0 +1,26 @@ + + + + Exe + netcoreapp3.1 + 5 + + + + + + + + + + + + + + + + + + + + From d91dc51823b6f00ed4231694b16273e501825908 Mon Sep 17 00:00:00 2001 From: Ruben Bartelink Date: Wed, 26 Feb 2020 13:28:13 +0000 Subject: [PATCH 35/51] Add Watchdog to Fc sln --- equinox-fc/Fc.sln | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/equinox-fc/Fc.sln b/equinox-fc/Fc.sln index bfb82d823..7496986dc 100644 --- a/equinox-fc/Fc.sln +++ b/equinox-fc/Fc.sln @@ -12,6 +12,8 @@ ProjectSection(SolutionItems) = preProject README.md = README.md EndProjectSection EndProject +Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Watchdog", "Watchdog\Watchdog.fsproj", "{B57DFA65-DC4F-4673-A5DC-7B00A08EFFF0}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -49,5 +51,17 @@ Global {AA589DC7-D0B7-4F95-BC29-6B89AF4E9280}.Release|x64.Build.0 = Release|Any CPU {AA589DC7-D0B7-4F95-BC29-6B89AF4E9280}.Release|x86.ActiveCfg = Release|Any CPU {AA589DC7-D0B7-4F95-BC29-6B89AF4E9280}.Release|x86.Build.0 = Release|Any CPU + {B57DFA65-DC4F-4673-A5DC-7B00A08EFFF0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B57DFA65-DC4F-4673-A5DC-7B00A08EFFF0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B57DFA65-DC4F-4673-A5DC-7B00A08EFFF0}.Debug|x64.ActiveCfg = Debug|Any CPU + {B57DFA65-DC4F-4673-A5DC-7B00A08EFFF0}.Debug|x64.Build.0 = Debug|Any CPU + {B57DFA65-DC4F-4673-A5DC-7B00A08EFFF0}.Debug|x86.ActiveCfg = Debug|Any CPU + {B57DFA65-DC4F-4673-A5DC-7B00A08EFFF0}.Debug|x86.Build.0 = Debug|Any CPU + {B57DFA65-DC4F-4673-A5DC-7B00A08EFFF0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B57DFA65-DC4F-4673-A5DC-7B00A08EFFF0}.Release|Any CPU.Build.0 = Release|Any CPU + {B57DFA65-DC4F-4673-A5DC-7B00A08EFFF0}.Release|x64.ActiveCfg = Release|Any CPU + {B57DFA65-DC4F-4673-A5DC-7B00A08EFFF0}.Release|x64.Build.0 = Release|Any CPU + {B57DFA65-DC4F-4673-A5DC-7B00A08EFFF0}.Release|x86.ActiveCfg = Release|Any CPU + {B57DFA65-DC4F-4673-A5DC-7B00A08EFFF0}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection EndGlobal From 947eb5df91239b54a7d5df0257cc478f386c5855 Mon Sep 17 00:00:00 2001 From: Ruben Bartelink Date: Thu, 27 Feb 2020 07:21:13 +0000 Subject: [PATCH 36/51] Tidy wiring --- equinox-fc/Watchdog/Program.fs | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/equinox-fc/Watchdog/Program.fs b/equinox-fc/Watchdog/Program.fs index 5d7cc4219..15d6e5693 100644 --- a/equinox-fc/Watchdog/Program.fs +++ b/equinox-fc/Watchdog/Program.fs @@ -331,6 +331,21 @@ let build (args : CmdParser.Arguments) = let access = Equinox.Cosmos.AccessStrategy.Custom (Checkpoint.Fold.isOrigin, Checkpoint.Fold.transmute) fun target -> Equinox.Cosmos.Resolver(context, codec, Checkpoint.Fold.fold, Checkpoint.Fold.initial, caching, access).Resolve(target, Equinox.AllowStale) let checkpoints = Checkpoint.CheckpointSeries(spec.groupName, Log.ForContext(), resolveCheckpointStream) + let handle (_stream, _span) : Async = failwith "TODO" // Handler.handleStreamEvents (Handler.tryHandle driveTransaction) + let sink = + Propulsion.Streams.StreamsProjector.Start( + Log.Logger, args.MaxReadAhead, args.MaxConcurrentStreams, handle, stats = Handler.Stats(Log.Logger)) + let connect () = let c = connectEs () in c.ReadConnection + let runPipeline = + EventStoreSource.Run( + Log.Logger, sink, checkpoints, connect, spec, EventStoreContext.tryMapEvent isTransactionStream, + args.MaxReadAhead, args.StatsInterval) + sink, runPipeline + | Choice2Of2 (srcCosmos, (auxDiscovery, aux, leaseId, startFromTail, maxDocuments, lagFrequency)) -> + let (discovery, database, container, connector) = srcCosmos.Cosmos.BuildConnectionDetails() + let connection = connector.Connect(AppName, discovery) |> Async.RunSynchronously + let cache = Equinox.Cache(AppName, sizeMb = 10) + let context = Equinox.Cosmos.Context(connection, database, container) let inventoryService = let inventoryId = InventoryId.parse "FC000" let maxTransactionsPerEpoch = 100 @@ -350,18 +365,6 @@ let build (args : CmdParser.Arguments) = let processor = Fc.Inventory.Processor.Service(transactionService, locations, inventoryService) let handle = Handler.handleStreamEvents (Handler.tryHandle processor.Push) - let sink = - Propulsion.Streams.StreamsProjector.Start( - Log.Logger, args.MaxReadAhead, args.MaxConcurrentStreams, handle, stats = Handler.Stats(Log.Logger)) - let connect () = let c = connectEs () in c.ReadConnection - let runPipeline = - EventStoreSource.Run( - Log.Logger, sink, checkpoints, connect, spec, EventStoreContext.tryMapEvent isTransactionStream, - args.MaxReadAhead, args.StatsInterval) - sink, runPipeline - | Choice2Of2 (srcCosmos, (auxDiscovery, aux, leaseId, startFromTail, maxDocuments, lagFrequency)) -> - let (discovery, database, container, connector) = srcCosmos.Cosmos.BuildConnectionDetails() - let handle (_stream, _span) : Async = failwith "TODO" // Handler.handleStreamEvents (Handler.tryHandle driveTransaction) let sink = Propulsion.Streams.StreamsProjector.Start( Log.Logger, args.MaxReadAhead, args.MaxConcurrentStreams, handle, stats = Handler.Stats(Log.Logger)) From 8d0e454bb5ae0705737a0fac6c6c27e951cab2bb Mon Sep 17 00:00:00 2001 From: Ruben Bartelink Date: Tue, 10 Mar 2020 13:08:36 +0000 Subject: [PATCH 37/51] Apply style changes from 4.2 --- equinox-fc/Domain/Inventory.fs | 8 ++--- equinox-fc/Domain/InventoryEpoch.fs | 24 +++++++-------- equinox-fc/Domain/InventorySeries.fs | 28 ++++++++--------- equinox-fc/Domain/InventoryTransaction.fs | 37 +++++++++++++++-------- equinox-fc/Domain/Location.fs | 2 +- equinox-fc/Domain/LocationEpoch.fs | 27 +++++++++-------- equinox-fc/Domain/LocationSeries.fs | 16 +++++----- equinox-fc/Watchdog/Handler.fs | 12 ++------ equinox-fc/Watchdog/Program.fs | 6 ++-- 9 files changed, 84 insertions(+), 76 deletions(-) diff --git a/equinox-fc/Domain/Inventory.fs b/equinox-fc/Domain/Inventory.fs index 14754695f..490d7c847 100644 --- a/equinox-fc/Domain/Inventory.fs +++ b/equinox-fc/Domain/Inventory.fs @@ -70,7 +70,7 @@ type Service2 internal (inventoryId, series : Series.Service, epochs : Epoch.Ser module internal Helpers = - let createService inventoryId maxTransactionsPerEpoch lookBackLimit (series, epochs) = + let create inventoryId maxTransactionsPerEpoch lookBackLimit (series, epochs) = let remainingEpochCapacity (state: Epoch.Fold.State) = let currentLen = state.ids.Count max 0 (maxTransactionsPerEpoch - currentLen) @@ -79,9 +79,9 @@ module internal Helpers = module Cosmos = let create inventoryId maxTransactionsPerEpoch lookBackLimit (context, cache) = - let series = Series.Cosmos.createService (context, cache) - let epochs = Epoch.Cosmos.createService (context, cache) - Helpers.createService inventoryId maxTransactionsPerEpoch lookBackLimit (series, epochs) + let series = Series.Cosmos.create (context, cache) + let epochs = Epoch.Cosmos.create (context, cache) + Helpers.create inventoryId maxTransactionsPerEpoch lookBackLimit (series, epochs) module Processor = diff --git a/equinox-fc/Domain/InventoryEpoch.fs b/equinox-fc/Domain/InventoryEpoch.fs index 5fca01731..74d5b6d50 100644 --- a/equinox-fc/Domain/InventoryEpoch.fs +++ b/equinox-fc/Domain/InventoryEpoch.fs @@ -3,13 +3,13 @@ /// See Inventory.Service for surface level API which manages the ingestion, including transitioning to a new Epoch when an epoch reaches 'full' state module Fc.Inventory.Epoch +let [] Category = "InventoryEpoch" +let streamName (inventoryId, epochId) = FsCodec.StreamName.compose Category [InventoryId.toString inventoryId; InventoryEpochId.toString epochId] + // NB - these types and the union case names reflect the actual storage formats and hence need to be versioned with care [] module Events = - let [] CategoryId = "InventoryEpoch" - let (|For|) (inventoryId, epochId) = FsCodec.StreamName.compose CategoryId [InventoryId.toString inventoryId; InventoryEpochId.toString epochId] - type TransactionRef = { transactionId : InventoryTransactionId } type Snapshotted = { closed: bool; ids : InventoryTransactionId[] } @@ -70,9 +70,7 @@ let decideSync capacity events (state : Fold.State) : Result * Events.Event list let state' = Fold.fold state events { isClosed = closed; added = allowing; rejected = residual; transactionIds = state'.ids }, events -type Service internal (log, resolve, maxAttempts) = - - let resolve (Events.For streamId) = Equinox.Stream(log, resolve streamId, maxAttempts) +type Service internal (resolve : InventoryId * InventoryEpochId -> Equinox.Stream) = /// Attempt ingestion of `events` into the cited Epoch. /// - None will be accepted if the Epoch is `closed` @@ -83,14 +81,16 @@ type Service internal (log, resolve, maxAttempts) = let stream = resolve (inventoryId, epochId) stream.Transact(decideSync capacity events) -let createService resolve = Service(Serilog.Log.ForContext(), resolve, maxAttempts = 2) +let create resolver = + let resolve locationId = + let stream = resolver (streamName locationId) + Equinox.Stream(Serilog.Log.ForContext(), stream, maxAttempts = 2) + Service (resolve) module Cosmos = - open Equinox.Cosmos - let accessStrategy = Equinox.Cosmos.AccessStrategy.Snapshot (Fold.isOrigin, Fold.snapshot) let resolve (context, cache) = - let cacheStrategy = CachingStrategy.SlidingWindow (cache, System.TimeSpan.FromMinutes 20.) - Resolver(context, Events.codec, Fold.fold, Fold.initial, cacheStrategy, accessStrategy).Resolve - let createService (context, cache) = createService (resolve (context, cache)) + let cacheStrategy = Equinox.Cosmos.CachingStrategy.SlidingWindow (cache, System.TimeSpan.FromMinutes 20.) + Equinox.Cosmos.Resolver(context, Events.codec, Fold.fold, Fold.initial, cacheStrategy, accessStrategy).Resolve + let create (context, cache) = create (resolve (context, cache)) diff --git a/equinox-fc/Domain/InventorySeries.fs b/equinox-fc/Domain/InventorySeries.fs index 2e0ce93fe..a93f5a67d 100644 --- a/equinox-fc/Domain/InventorySeries.fs +++ b/equinox-fc/Domain/InventorySeries.fs @@ -3,13 +3,13 @@ /// See Inventory.Service for the surface API which manages the writing module Fc.Inventory.Series +let [] Category = "InventorySeries" +let streamName inventoryId = FsCodec.StreamName.create Category (InventoryId.toString inventoryId) + // NB - these types and the union case names reflect the actual storage formats and hence need to be versioned with care [] module Events = - let [] CategoryId = "InventorySeries" - let (|For|) inventoryId = FsCodec.StreamName.create CategoryId (InventoryId.toString inventoryId) - type Started = { epoch : InventoryEpochId } type Event = | Started of Started @@ -30,9 +30,7 @@ let interpretAdvanceIngestionEpoch epochId (state : Fold.State) = if queryActiveEpoch state >= epochId then [] else [Events.Started { epoch = epochId }] -type Service internal (log, resolve, maxAttempts) = - - let resolve (Events.For streamId) = Equinox.Stream(log, resolve streamId, maxAttempts) +type Service internal (resolve : InventoryId -> Equinox.Stream) = member __.ReadIngestionEpoch(inventoryId) : Async = let stream = resolve inventoryId @@ -42,19 +40,21 @@ type Service internal (log, resolve, maxAttempts) = let stream = resolve inventoryId stream.Transact(interpretAdvanceIngestionEpoch epochId) -let createService resolve = - Service(Serilog.Log.ForContext(), resolve, maxAttempts = 2) +let create resolver = + let resolve locationId = + let stream = resolver (streamName locationId) + Equinox.Stream(Serilog.Log.ForContext(), stream, maxAttempts = 2) + Service (resolve) module Cosmos = - open Equinox.Cosmos - + let accessStrategy = Equinox.Cosmos.AccessStrategy.LatestKnownEvent let resolve (context, cache) = - let cacheStrategy = CachingStrategy.SlidingWindow (cache, System.TimeSpan.FromMinutes 20.) + let cacheStrategy = Equinox.Cosmos.CachingStrategy.SlidingWindow (cache, System.TimeSpan.FromMinutes 20.) // For this stream, we uniformly use stale reads as: // a) we don't require any information from competing writers // b) while there are competing writers [which might cause us to have to retry a Transact], this should be infrequent let opt = Equinox.ResolveOption.AllowStale - fun id -> Resolver(context, Events.codec, Fold.fold, Fold.initial, cacheStrategy, AccessStrategy.LatestKnownEvent).Resolve(id, opt) - let createService (context, cache) = - createService (resolve (context, cache)) + fun id -> Equinox.Cosmos.Resolver(context, Events.codec, Fold.fold, Fold.initial, cacheStrategy, accessStrategy).Resolve(id, opt) + let create (context, cache) = + create (resolve (context, cache)) diff --git a/equinox-fc/Domain/InventoryTransaction.fs b/equinox-fc/Domain/InventoryTransaction.fs index 9ee61b472..ecb9acbc1 100644 --- a/equinox-fc/Domain/InventoryTransaction.fs +++ b/equinox-fc/Domain/InventoryTransaction.fs @@ -9,13 +9,13 @@ /// This represents the case where a 'happy path' actor died, or experienced another impediment on the path. module Fc.Inventory.Transaction +let [] Category = "InventoryTransaction" +let streamName transactionId = FsCodec.StreamName.create Category (InventoryTransactionId.toString transactionId) + // NB - these types and the union case names reflect the actual storage formats and hence need to be versioned with care [] module Events = - let [] CategoryId = "InventoryTransaction" - let (|For|) transactionId = FsCodec.StreamName.create CategoryId (InventoryTransactionId.toString transactionId) - type AdjustmentRequested = { location : LocationId; quantity : int } type TransferRequested = { source : LocationId; destination : LocationId; quantity : int } type Removed = { balance : int } @@ -130,28 +130,28 @@ let decide update (state : Fold.State) : Action * Events.Event list = let state' = Fold.fold state events Fold.nextAction state', events -type Service internal (log, resolve, maxAttempts) = - - let resolve (Events.For streamId) = Equinox.Stream(log, resolve streamId, maxAttempts) +type Service internal (resolve : InventoryTransactionId -> Equinox.Stream) = member __.Apply(transactionId, update) : Async = let stream = resolve transactionId stream.Transact(decide update) -let createService resolve = Service(Serilog.Log.ForContext(), resolve, maxAttempts = 3) +let create resolver = + let resolve inventoryTransactionId = + let stream = resolver (streamName inventoryTransactionId) + Equinox.Stream(Serilog.Log.ForContext(), stream, maxAttempts = 2) + Service (resolve) module Cosmos = - open Equinox.Cosmos - // in the happy path case, the event stream will typically be short, and the state cached, so snapshotting is less critical let accessStrategy = Equinox.Cosmos.AccessStrategy.Unoptimized // ... and there will generally be a single actor touching it at a given time, so we don't need to do a load (which would be more expensive than normal given the `accessStrategy`) before we sync let opt = Equinox.AllowStale let resolve (context, cache) = - let cacheStrategy = CachingStrategy.SlidingWindow (cache, System.TimeSpan.FromMinutes 20.) - fun id -> Resolver(context, Events.codec, Fold.fold, Fold.initial, cacheStrategy, accessStrategy).Resolve(id, opt) - let createService (context, cache) = createService (resolve (context, cache)) + let cacheStrategy = Equinox.Cosmos.CachingStrategy.SlidingWindow (cache, System.TimeSpan.FromMinutes 20.) + fun id -> Equinox.Cosmos.Resolver(context, Events.codec, Fold.fold, Fold.initial, cacheStrategy, accessStrategy).Resolve(id, opt) + let createService (context, cache) = create (resolve (context, cache)) /// Handles requirement to infer when a transaction is 'stuck' /// Note we don't want to couple to the state in a deep manner; thus we track: @@ -188,3 +188,16 @@ module Watchdog = | Fold.Active startTime when startTime < cutoffTime -> Stuck | Fold.Active _ -> Active | Fold.Completed -> Complete + + let fold : Events.TimestampAndEvent seq -> Fold.State = + Fold.fold Fold.initial + + let (|FoldToWatchdogState|) events : Fold.State = + events + |> Seq.choose Events.codec.TryDecode + |> fold + + let (|Match|_|) = function + | FsCodec.StreamName.CategoryAndId (Category, InventoryTransactionId.Parse transId), FoldToWatchdogState state -> + Some (transId, state) + | _ -> None \ No newline at end of file diff --git a/equinox-fc/Domain/Location.fs b/equinox-fc/Domain/Location.fs index ed4fde94a..37c08479d 100644 --- a/equinox-fc/Domain/Location.fs +++ b/equinox-fc/Domain/Location.fs @@ -44,5 +44,5 @@ module Cosmos = let createService (zeroBalance, toBalanceCarriedForward, shouldClose) (context, cache, maxAttempts) = let series = Series.Cosmos.createService (context, cache, maxAttempts) - let epochs = Epoch.Cosmos.createService (context, cache, maxAttempts) + let epochs = Epoch.Cosmos.create (context, cache, maxAttempts) create (zeroBalance, toBalanceCarriedForward, shouldClose) (series, epochs) diff --git a/equinox-fc/Domain/LocationEpoch.fs b/equinox-fc/Domain/LocationEpoch.fs index b5c32f86f..a356c6a79 100644 --- a/equinox-fc/Domain/LocationEpoch.fs +++ b/equinox-fc/Domain/LocationEpoch.fs @@ -3,13 +3,13 @@ /// See Location.Service for the logic that allows competing readers/writers to co-operate in bringing this about module Fc.Location.Epoch +let [] Category = "LocationEpoch" +let streamName (locationId, epochId) = FsCodec.StreamName.compose Category [LocationId.toString locationId; LocationEpochId.toString epochId] + // NOTE - these types and the union case names reflect the actual storage formats and hence need to be versioned with care [] module Events = - let [] CategoryId = "LocationEpoch" - let (|For|) (locationId, epochId) = FsCodec.StreamName.compose CategoryId [LocationId.toString locationId; LocationEpochId.toString epochId] - type CarriedForward = { initial : int; recentTransactions : InventoryTransactionId[] } type Event = | CarriedForward of CarriedForward @@ -24,7 +24,7 @@ module Fold = type State = | Initial - | Open of Record list // reverse order, i.e. most revent first + | Open of Record list // reverse order, i.e. most recent first | Closed of Record list // trimmed and Record = | Init of Events.CarriedForward @@ -127,22 +127,23 @@ let decide transactionId command (state: Fold.State) = | Fold.Open (Fold.Current cur) -> (if accepted then Accepted cur else Denied), events | s -> failwithf "Unexpected state %A" s -type Service internal (log, resolve, maxAttempts) = - - let resolve (Events.For id) = Equinox.Stream(log, resolve id, maxAttempts) +type Service internal (resolve : LocationId * LocationEpochId -> Equinox.Stream) = member __.Sync<'R>(locationId, epochId, prevEpochBalanceCarriedForward, decide, shouldClose) : Async> = let stream = resolve (locationId, epochId) stream.Transact(sync prevEpochBalanceCarriedForward decide shouldClose) -let create resolve maxAttempts = Service(Serilog.Log.ForContext(), resolve, maxAttempts = maxAttempts) +let create resolver maxAttempts = + let resolve locId = + let stream = resolver (streamName locId) + Equinox.Stream(Serilog.Log.ForContext(), stream, maxAttempts = maxAttempts) + Service (resolve) module Cosmos = - open Equinox.Cosmos - + let accessStrategy = Equinox.Cosmos.AccessStrategy.Unoptimized let resolve (context, cache) = - let cacheStrategy = CachingStrategy.SlidingWindow (cache, System.TimeSpan.FromMinutes 20.) - Resolver(context, Events.codec, Fold.fold, Fold.initial, cacheStrategy, AccessStrategy.Unoptimized).Resolve - let createService (context, cache, maxAttempts) = + let cacheStrategy = Equinox.Cosmos.CachingStrategy.SlidingWindow (cache, System.TimeSpan.FromMinutes 20.) + Equinox.Cosmos.Resolver(context, Events.codec, Fold.fold, Fold.initial, cacheStrategy, accessStrategy).Resolve + let create (context, cache, maxAttempts) = create (resolve (context, cache)) maxAttempts diff --git a/equinox-fc/Domain/LocationSeries.fs b/equinox-fc/Domain/LocationSeries.fs index 72306eb40..35c77707f 100644 --- a/equinox-fc/Domain/LocationSeries.fs +++ b/equinox-fc/Domain/LocationSeries.fs @@ -1,13 +1,13 @@ /// Manages the active epoch for a given Location module Fc.Location.Series +let [] Category = "LocationSeries" +let streamName locationId = FsCodec.StreamName.create Category (LocationId.toString locationId) + // NOTE - these types and the union case names reflect the actual storage formats and hence need to be versioned with care [] module Events = - let [] CategoryId = "LocationSeries" - let (|For|) locationId = FsCodec.StreamName.create CategoryId (LocationId.toString locationId) - type Started = { epoch : LocationEpochId } type Event = | Started of Started @@ -27,9 +27,7 @@ let interpretAdvanceIngestionEpoch (epochId : LocationEpochId) (state : Fold.Sta [if state |> Option.forall (fun s -> s < epochId) then yield Events.Started { epoch = epochId }] -type Service internal (log, resolve, maxAttempts) = - - let resolve (Events.For id) = Equinox.Stream(log, resolve id, maxAttempts) +type Service internal (resolve : LocationId -> Equinox.Stream) = member __.TryReadIngestionEpoch(locationId) : Async = let stream = resolve locationId @@ -39,7 +37,11 @@ type Service internal (log, resolve, maxAttempts) = let stream = resolve locationId stream.Transact(interpretAdvanceIngestionEpoch epochId) -let create resolve maxAttempts = Service(Serilog.Log.ForContext(), resolve, maxAttempts) +let create resolver maxAttempts = + let resolve locId = + let stream = resolver (streamName locId) + Equinox.Stream(Serilog.Log.ForContext(), stream, maxAttempts = maxAttempts) + Service (resolve) module Cosmos = diff --git a/equinox-fc/Watchdog/Handler.fs b/equinox-fc/Watchdog/Handler.fs index 908923a04..b96deb7a3 100644 --- a/equinox-fc/Watchdog/Handler.fs +++ b/equinox-fc/Watchdog/Handler.fs @@ -22,18 +22,10 @@ type Stats(log, ?statsInterval, ?stateInterval) = open Fc.Inventory.Transaction -let fold : Watchdog.Events.TimestampAndEvent seq -> Watchdog.Fold.State = - Watchdog.Fold.fold Watchdog.Fold.initial - -let (|FoldToWatchdogState|) (span : Propulsion.Streams.StreamSpan<_>) : Watchdog.Fold.State = - span.events - |> Seq.choose Watchdog.Events.codec.TryDecode - |> fold - let tryHandle driveTransaction (stream, span : Propulsion.Streams.StreamSpan<_>) : Async = async { let processingStuckCutoff = let now = DateTimeOffset.UtcNow in now.AddSeconds -10. - match stream, span with - | FsCodec.StreamName.CategoryAndId (Events.CategoryId, InventoryTransactionId.Parse transId), FoldToWatchdogState state -> + match stream, span.events with + | Watchdog.Match (transId, state) -> match Watchdog.categorize processingStuckCutoff state with | Watchdog.Complete -> return Outcome.Completed diff --git a/equinox-fc/Watchdog/Program.fs b/equinox-fc/Watchdog/Program.fs index 15d6e5693..610f722a5 100644 --- a/equinox-fc/Watchdog/Program.fs +++ b/equinox-fc/Watchdog/Program.fs @@ -312,7 +312,7 @@ module EventStoreContext = | e when not e.IsJson || e.EventStreamId.StartsWith "$" || not (isWhitelisted e.EventStreamId) -> None | PropulsionStreamEvent e -> Some e -let transactionStreamPrefix = sprintf "%s-" Fc.Inventory.Transaction.Events.CategoryId +let transactionStreamPrefix = sprintf "%s-" Fc.Inventory.Transaction.Category let isTransactionStream : string -> bool = function sn -> sn.StartsWith transactionStreamPrefix let build (args : CmdParser.Arguments) = @@ -355,8 +355,8 @@ let build (args : CmdParser.Arguments) = let locations = let zeroBalance : Fc.Location.Epoch.Events.CarriedForward = { initial = 0; recentTransactions = [||] } let chooseTransactionIds = function - | Fc.Location.Epoch.Fold.Init { recentTransactions = ids } -> Seq.ofArray ids - | Fc.Location.Epoch.Fold.Step { id = id } -> Seq.singleton id + | Fc.Location.Epoch.Fold.Init { recentTransactions = ids } -> Seq.ofArray ids + | Fc.Location.Epoch.Fold.Step { id = id } -> Seq.singleton id let toBalanceCarriedForward (Fc.Location.Epoch.Fold.Current cur as records) : Fc.Location.Epoch.Events.CarriedForward = { initial = cur; recentTransactions = records |> Seq.collect chooseTransactionIds |> Seq.truncate 5 |> Seq.toArray } let shouldClose x = false From 099cdf7b6a8f2f306bbc86a0a41234f514be0de8 Mon Sep 17 00:00:00 2001 From: Ruben Bartelink Date: Tue, 10 Mar 2020 13:24:04 +0000 Subject: [PATCH 38/51] Stragglers --- equinox-fc/Domain.Tests/LocationTests.fs | 8 ++++---- equinox-fc/Domain/InventoryEpoch.fs | 10 +++++----- equinox-fc/Domain/InventorySeries.fs | 4 ++-- equinox-fc/Domain/InventoryTransaction.fs | 4 ++-- equinox-fc/Domain/LocationEpoch.fs | 8 ++++---- equinox-fc/Domain/LocationSeries.fs | 8 ++++---- 6 files changed, 21 insertions(+), 21 deletions(-) diff --git a/equinox-fc/Domain.Tests/LocationTests.fs b/equinox-fc/Domain.Tests/LocationTests.fs index 536505c57..7e2217f7d 100644 --- a/equinox-fc/Domain.Tests/LocationTests.fs +++ b/equinox-fc/Domain.Tests/LocationTests.fs @@ -15,16 +15,16 @@ module Location = module Series = - let resolve store = Resolver(store, Series.Events.codec, Series.Fold.fold, Series.Fold.initial).Resolve + let resolver store = Resolver(store, Series.Events.codec, Series.Fold.fold, Series.Fold.initial).Resolve module Epoch = - let resolve store = Resolver(store, Epoch.Events.codec, Epoch.Fold.fold, Epoch.Fold.initial).Resolve + let resolver store = Resolver(store, Epoch.Events.codec, Epoch.Fold.fold, Epoch.Fold.initial).Resolve let createService (zeroBalance, shouldClose) store = let maxAttempts = Int32.MaxValue - let series = Series.create (Series.resolve store) maxAttempts - let epochs = Epoch.create (Epoch.resolve store) maxAttempts + let series = Series.create (Series.resolver store) maxAttempts + let epochs = Epoch.create (Epoch.resolver store) maxAttempts create (zeroBalance, shouldClose) (series, epochs) let run (service : Location.Service) (IdsAtLeastOne locations, deltas : _[], transactionId) = Async.RunSynchronously <| async { diff --git a/equinox-fc/Domain/InventoryEpoch.fs b/equinox-fc/Domain/InventoryEpoch.fs index 74d5b6d50..9942b5f2e 100644 --- a/equinox-fc/Domain/InventoryEpoch.fs +++ b/equinox-fc/Domain/InventoryEpoch.fs @@ -82,15 +82,15 @@ type Service internal (resolve : InventoryId * InventoryEpochId -> Equinox.Strea stream.Transact(decideSync capacity events) let create resolver = - let resolve locationId = - let stream = resolver (streamName locationId) + let resolve ids = + let stream = resolver (streamName ids) Equinox.Stream(Serilog.Log.ForContext(), stream, maxAttempts = 2) - Service (resolve) + Service(resolve) module Cosmos = let accessStrategy = Equinox.Cosmos.AccessStrategy.Snapshot (Fold.isOrigin, Fold.snapshot) - let resolve (context, cache) = + let resolver (context, cache) = let cacheStrategy = Equinox.Cosmos.CachingStrategy.SlidingWindow (cache, System.TimeSpan.FromMinutes 20.) Equinox.Cosmos.Resolver(context, Events.codec, Fold.fold, Fold.initial, cacheStrategy, accessStrategy).Resolve - let create (context, cache) = create (resolve (context, cache)) + let create (context, cache) = create (resolver (context, cache)) diff --git a/equinox-fc/Domain/InventorySeries.fs b/equinox-fc/Domain/InventorySeries.fs index a93f5a67d..011f24fee 100644 --- a/equinox-fc/Domain/InventorySeries.fs +++ b/equinox-fc/Domain/InventorySeries.fs @@ -49,7 +49,7 @@ let create resolver = module Cosmos = let accessStrategy = Equinox.Cosmos.AccessStrategy.LatestKnownEvent - let resolve (context, cache) = + let resolver (context, cache) = let cacheStrategy = Equinox.Cosmos.CachingStrategy.SlidingWindow (cache, System.TimeSpan.FromMinutes 20.) // For this stream, we uniformly use stale reads as: // a) we don't require any information from competing writers @@ -57,4 +57,4 @@ module Cosmos = let opt = Equinox.ResolveOption.AllowStale fun id -> Equinox.Cosmos.Resolver(context, Events.codec, Fold.fold, Fold.initial, cacheStrategy, accessStrategy).Resolve(id, opt) let create (context, cache) = - create (resolve (context, cache)) + create (resolver (context, cache)) diff --git a/equinox-fc/Domain/InventoryTransaction.fs b/equinox-fc/Domain/InventoryTransaction.fs index ecb9acbc1..a6f8d5bf3 100644 --- a/equinox-fc/Domain/InventoryTransaction.fs +++ b/equinox-fc/Domain/InventoryTransaction.fs @@ -148,10 +148,10 @@ module Cosmos = let accessStrategy = Equinox.Cosmos.AccessStrategy.Unoptimized // ... and there will generally be a single actor touching it at a given time, so we don't need to do a load (which would be more expensive than normal given the `accessStrategy`) before we sync let opt = Equinox.AllowStale - let resolve (context, cache) = + let resolver (context, cache) = let cacheStrategy = Equinox.Cosmos.CachingStrategy.SlidingWindow (cache, System.TimeSpan.FromMinutes 20.) fun id -> Equinox.Cosmos.Resolver(context, Events.codec, Fold.fold, Fold.initial, cacheStrategy, accessStrategy).Resolve(id, opt) - let createService (context, cache) = create (resolve (context, cache)) + let createService (context, cache) = create (resolver (context, cache)) /// Handles requirement to infer when a transaction is 'stuck' /// Note we don't want to couple to the state in a deep manner; thus we track: diff --git a/equinox-fc/Domain/LocationEpoch.fs b/equinox-fc/Domain/LocationEpoch.fs index a356c6a79..330e3dd2d 100644 --- a/equinox-fc/Domain/LocationEpoch.fs +++ b/equinox-fc/Domain/LocationEpoch.fs @@ -134,16 +134,16 @@ type Service internal (resolve : LocationId * LocationEpochId -> Equinox.Stream< stream.Transact(sync prevEpochBalanceCarriedForward decide shouldClose) let create resolver maxAttempts = - let resolve locId = - let stream = resolver (streamName locId) + let resolve locationId = + let stream = resolver (streamName locationId) Equinox.Stream(Serilog.Log.ForContext(), stream, maxAttempts = maxAttempts) Service (resolve) module Cosmos = let accessStrategy = Equinox.Cosmos.AccessStrategy.Unoptimized - let resolve (context, cache) = + let resolver (context, cache) = let cacheStrategy = Equinox.Cosmos.CachingStrategy.SlidingWindow (cache, System.TimeSpan.FromMinutes 20.) Equinox.Cosmos.Resolver(context, Events.codec, Fold.fold, Fold.initial, cacheStrategy, accessStrategy).Resolve let create (context, cache, maxAttempts) = - create (resolve (context, cache)) maxAttempts + create (resolver (context, cache)) maxAttempts diff --git a/equinox-fc/Domain/LocationSeries.fs b/equinox-fc/Domain/LocationSeries.fs index 35c77707f..e113a7c67 100644 --- a/equinox-fc/Domain/LocationSeries.fs +++ b/equinox-fc/Domain/LocationSeries.fs @@ -38,8 +38,8 @@ type Service internal (resolve : LocationId -> Equinox.Stream(), stream, maxAttempts = maxAttempts) Service (resolve) @@ -47,9 +47,9 @@ module Cosmos = open Equinox.Cosmos - let resolve (context, cache) = + let resolver (context, cache) = let cacheStrategy = CachingStrategy.SlidingWindow (cache, System.TimeSpan.FromMinutes 20.) let opt = Equinox.ResolveOption.AllowStale fun id -> Resolver(context, Events.codec, Fold.fold, Fold.initial, cacheStrategy, AccessStrategy.LatestKnownEvent).Resolve(id, opt) let createService (context, cache, maxAttempts) = - create (resolve (context, cache)) maxAttempts + create (resolver (context, cache)) maxAttempts From e64fe37d5d513a3f4c7289f9b96de67425fdf9f7 Mon Sep 17 00:00:00 2001 From: Ruben Bartelink Date: Sun, 12 Apr 2020 22:38:42 +0100 Subject: [PATCH 39/51] wip --- README.md | 3 +- dotnet-templates.sln | 7 ++- equinox-fc/.template.config/template.json | 2 +- .../Watchdog.fsproj => Host/Host.fsproj} | 1 - equinox-fc/{Watchdog => Host}/Program.fs | 27 ++++------- equinox-fc/Watchdog/Handler.fs | 46 ------------------- 6 files changed, 18 insertions(+), 68 deletions(-) rename equinox-fc/{Watchdog/Watchdog.fsproj => Host/Host.fsproj} (95%) rename equinox-fc/{Watchdog => Host}/Program.fs (95%) delete mode 100644 equinox-fc/Watchdog/Handler.fs diff --git a/README.md b/README.md index 2bf8ca4ea..ec4653cd4 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,8 @@ These templates focus solely on Consistent Processing using Equinox Stores: - [`eqxweb`](equinox-web/README.md) - Boilerplate for an ASP .NET Core 2 Web App, with an associated storage-independent Domain project using [Equinox](https://github.com/jet/equinox). - [`eqxwebcs`](equinox-web-csharp/README.md) - Boilerplate for an ASP .NET Core 2 Web App, with an associated storage-independent Domain project using [Equinox](https://github.com/jet/equinox), _ported to C#_. - [`eqxtestbed`](equinox-testbed/README.md) - Host that allows running back-to-back benchmarks when prototyping models using [Equinox](https://github.com/jet/equinox), using different stores and/or store configuration parameters. -- [`eqxfc`](equinox-fc/README.md) - Samples showcasing various modeling and testing techniques such as (FsCheck-based) unit tests and use of `MemoryStore` for integration tests. + +- [`eqxFc`](equinox-fc/README.md) - Samples showcasing various modeling and testing techniques such as (FsCheck-based) unit tests and use of `MemoryStore` for integration tests. ## [Propulsion](https://github.com/jet/propulsion) related diff --git a/dotnet-templates.sln b/dotnet-templates.sln index 085f27078..a8c34bce8 100644 --- a/dotnet-templates.sln +++ b/dotnet-templates.sln @@ -107,7 +107,7 @@ Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Domain", "equinox-fc\Domain EndProject Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Domain.Tests", "equinox-fc\Domain.Tests\Domain.Tests.fsproj", "{49890A45-D6C2-4EF6-87AD-39960E03E254}" EndProject -Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Watchdog", "equinox-fc\Watchdog\Watchdog.fsproj", "{46B8B7C9-3334-4C13-A339-57571C14F2B9}" +Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Host", "equinox-fc\Host\Host.fsproj", "{6CB10946-AC56-4DE7-AB65-F6B13B86C703}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -197,6 +197,10 @@ Global {46B8B7C9-3334-4C13-A339-57571C14F2B9}.Debug|Any CPU.Build.0 = Debug|Any CPU {46B8B7C9-3334-4C13-A339-57571C14F2B9}.Release|Any CPU.ActiveCfg = Release|Any CPU {46B8B7C9-3334-4C13-A339-57571C14F2B9}.Release|Any CPU.Build.0 = Release|Any CPU + {6CB10946-AC56-4DE7-AB65-F6B13B86C703}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6CB10946-AC56-4DE7-AB65-F6B13B86C703}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6CB10946-AC56-4DE7-AB65-F6B13B86C703}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6CB10946-AC56-4DE7-AB65-F6B13B86C703}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {F66A5BFE-7C81-44DC-97DE-3FD8C83B8F06} = {B72FFAAE-7801-41B2-86F5-FD90E97A30F7} @@ -214,6 +218,7 @@ Global {49890A45-D6C2-4EF6-87AD-39960E03E254} = {4946576F-1558-49ED-A272-6F4D92FB0031} {7B96FCF8-0BB5-4494-A143-628882A6E50A} = {DAE9E2B9-EDA2-4064-B0CE-FD5294549374} {9AFF6138-B63B-4EBF-B86B-4F626E1F1ADF} = {DAE9E2B9-EDA2-4064-B0CE-FD5294549374} + {6CB10946-AC56-4DE7-AB65-F6B13B86C703} = {4946576F-1558-49ED-A272-6F4D92FB0031} {5A45EF21-576B-4B40-86BD-F5960ECD66BF} = {DAE9E2B9-EDA2-4064-B0CE-FD5294549374} {83BA87C3-6288-40F4-BC4F-EC3A54586CDF} = {DAE9E2B9-EDA2-4064-B0CE-FD5294549374} {46B8B7C9-3334-4C13-A339-57571C14F2B9} = {4946576F-1558-49ED-A272-6F4D92FB0031} diff --git a/equinox-fc/.template.config/template.json b/equinox-fc/.template.config/template.json index b8bac62f6..135b57883 100644 --- a/equinox-fc/.template.config/template.json +++ b/equinox-fc/.template.config/template.json @@ -12,7 +12,7 @@ }, "identity": "Equinox.Fc", "name": "Equinox Fc Example", - "shortName": "fcInventory", + "shortName": "eqxFc", "sourceName": "Fc", "preferNameDirectory": true } \ No newline at end of file diff --git a/equinox-fc/Watchdog/Watchdog.fsproj b/equinox-fc/Host/Host.fsproj similarity index 95% rename from equinox-fc/Watchdog/Watchdog.fsproj rename to equinox-fc/Host/Host.fsproj index ebc91054c..02dd50b03 100644 --- a/equinox-fc/Watchdog/Watchdog.fsproj +++ b/equinox-fc/Host/Host.fsproj @@ -7,7 +7,6 @@ - diff --git a/equinox-fc/Watchdog/Program.fs b/equinox-fc/Host/Program.fs similarity index 95% rename from equinox-fc/Watchdog/Program.fs rename to equinox-fc/Host/Program.fs index 610f722a5..6d429844a 100644 --- a/equinox-fc/Watchdog/Program.fs +++ b/equinox-fc/Host/Program.fs @@ -1,4 +1,4 @@ -module Fc.Watchdog.Program +module Fc.Host.Program open Propulsion.Cosmos open Propulsion.EventStore @@ -10,7 +10,7 @@ module EnvVar = let tryGet varName : string option = Environment.GetEnvironmentVariable varName |> Option.ofObj let set varName value : unit = Environment.SetEnvironmentVariable(varName, value) -module Settings = +module Configuration = let private initEnvVar var key loadF = if None = EnvVar.tryGet var then @@ -21,7 +21,7 @@ module Settings = // e.g. initEnvVar "EQUINOX_COSMOS_COLLECTION" "CONSUL KEY" readFromConsul () // TODO add any custom logic preprocessing commandline arguments and/or gathering custom defaults from external sources, etc -module CmdParser = +module Args = exception MissingArg of string let private getEnvVarForArgumentOrThrow varName argName = @@ -42,7 +42,6 @@ module CmdParser = | [] Verbose | [] VerboseConsole - | [] Es of ParseResults | [] SrcCosmos of ParseResults interface IArgParserTemplate with @@ -283,28 +282,20 @@ module CmdParser = module Logging = - let initialize verbose changeLogVerbose = + let initialize verbose = Log.Logger <- LoggerConfiguration() .Destructure.FSharpTypes() .Enrich.FromLogContext() |> fun c -> if verbose then c.MinimumLevel.Debug() else c - // LibLog writes to the global logger, so we need to control the emission - |> fun c -> let cfpl = if changeLogVerbose then Serilog.Events.LogEventLevel.Debug else Serilog.Events.LogEventLevel.Warning - c.MinimumLevel.Override("Microsoft.Azure.Documents.ChangeFeedProcessor", cfpl) - |> fun c -> let isCfp429a = Filters.Matching.FromSource("Microsoft.Azure.Documents.ChangeFeedProcessor.LeaseManagement.DocumentServiceLeaseUpdater").Invoke - let isCfp429b = Filters.Matching.FromSource("Microsoft.Azure.Documents.ChangeFeedProcessor.PartitionManagement.LeaseRenewer").Invoke - let isCfp429c = Filters.Matching.FromSource("Microsoft.Azure.Documents.ChangeFeedProcessor.PartitionManagement.PartitionLoadBalancer").Invoke - let isCfp429d = Filters.Matching.FromSource("Microsoft.Azure.Documents.ChangeFeedProcessor.FeedProcessing.PartitionProcessor").Invoke - let isCfp x = isCfp429a x || isCfp429b x || isCfp429c x || isCfp429d x - if changeLogVerbose then c else c.Filter.ByExcluding(fun x -> isCfp x) |> fun c -> let t = "[{Timestamp:HH:mm:ss} {Level:u3}] {partitionKeyRangeId,2} {Message:lj} {NewLine}{Exception}" c.WriteTo.Console(theme=Sinks.SystemConsole.Themes.AnsiConsoleTheme.Code, outputTemplate=t) |> fun c -> c.CreateLogger() -let [] AppName = "Watchdog" +let [] AppName = "Host" module EventStoreContext = + let cache = Equinox.Cache(AppName, sizeMb = 10) let create connection = Equinox.EventStore.Context(connection, Equinox.EventStore.BatchingPolicy(maxBatchSize=500)) let tryMapEvent isWhitelisted (x : EventStore.ClientAPI.ResolvedEvent) = @@ -315,7 +306,7 @@ module EventStoreContext = let transactionStreamPrefix = sprintf "%s-" Fc.Inventory.Transaction.Category let isTransactionStream : string -> bool = function sn -> sn.StartsWith transactionStreamPrefix -let build (args : CmdParser.Arguments) = +let build (args : Args.Arguments) = match args.SourceParams() with | Choice1Of2 (srcE, cosmos, spec) -> let connectEs () = srcE.Connect(Log.Logger, Log.Logger, AppName, Equinox.EventStore.ConnectionStrategy.ClusterSingle Equinox.EventStore.NodePreference.PreferSlave) @@ -381,7 +372,7 @@ let build (args : CmdParser.Arguments) = sink, runPipeline let run argv = - try let args = CmdParser.parse argv + try let args = Args.parse argv Logging.initialize args.Verbose args.VerboseConsole Settings.initialize () let projector, runSourcePipeline = build args @@ -389,7 +380,7 @@ let run argv = projector.AwaitCompletion() |> Async.RunSynchronously if projector.RanToCompletion then 0 else 2 with :? Argu.ArguParseException as e -> eprintfn "%s" e.Message; 1 - | CmdParser.MissingArg msg -> eprintfn "%s" msg; 1 + | Args.MissingArg msg -> eprintfn "%s" msg; 1 | e -> Log.Fatal(e, "Exiting"); 1 [] diff --git a/equinox-fc/Watchdog/Handler.fs b/equinox-fc/Watchdog/Handler.fs deleted file mode 100644 index b96deb7a3..000000000 --- a/equinox-fc/Watchdog/Handler.fs +++ /dev/null @@ -1,46 +0,0 @@ -module Fc.Watchdog.Handler - -open System - -[] -type Outcome = Completed | Deferred | Nudged - -type Stats(log, ?statsInterval, ?stateInterval) = - inherit Propulsion.Streams.Projector.Stats(log, defaultArg statsInterval (TimeSpan.FromMinutes 1.), defaultArg stateInterval (TimeSpan.FromMinutes 5.)) - - let mutable completed, deferred, resolved = 0, 0, 0 - - override __.HandleOk res = res |> function - | Outcome.Completed -> completed <- completed + 1 - | Outcome.Deferred -> deferred <- deferred + 1 - | Outcome.Nudged -> resolved <- resolved + 1 - - override __.DumpStats () = - if completed <> 0 || deferred <> 0 then - log.Information(" Completed {completed} Deferred {deferred} Resolved {resolved}", completed, deferred, resolved) - completed <- 0; deferred <- 0; resolved <- 0 - -open Fc.Inventory.Transaction - -let tryHandle driveTransaction (stream, span : Propulsion.Streams.StreamSpan<_>) : Async = async { - let processingStuckCutoff = let now = DateTimeOffset.UtcNow in now.AddSeconds -10. - match stream, span.events with - | Watchdog.Match (transId, state) -> - match Watchdog.categorize processingStuckCutoff state with - | Watchdog.Complete -> - return Outcome.Completed - | Watchdog.Active -> - // Visiting every second is not too expensive; we don't want to be warming the data center for no purpose - do! Async.Sleep 1000 // ms - return Outcome.Deferred - | Watchdog.Stuck -> - do! driveTransaction transId - return Outcome.Nudged - | _ -> return failwith "Not a relevant event" } - -let handleStreamEvents tryHandle (stream, span : Propulsion.Streams.StreamSpan<_>) : Async = async { - match! tryHandle (stream, span) with - // if we're deferring, we need to retain the events (all of them, as the time of the first one is salient) - | Outcome.Deferred as r -> return span.index, r - // if it's now complete (either organically, or with our help), we can release all the events pertaining to this transaction - | Outcome.Completed | Outcome.Nudged as r -> return span.index + span.events.LongLength, r } From cbd65629a4e3d69c28f23a8d784ad85eeb0e89fa Mon Sep 17 00:00:00 2001 From: Ruben Bartelink Date: Sun, 12 Apr 2020 22:45:42 +0100 Subject: [PATCH 40/51] Fix typo --- equinox-fc/Host/Program.fs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/equinox-fc/Host/Program.fs b/equinox-fc/Host/Program.fs index 6d429844a..529d902d2 100644 --- a/equinox-fc/Host/Program.fs +++ b/equinox-fc/Host/Program.fs @@ -18,7 +18,7 @@ module Configuration = EnvVar.set var (loadF key) let initialize () = - // e.g. initEnvVar "EQUINOX_COSMOS_COLLECTION" "CONSUL KEY" readFromConsul + // e.g. initEnvVar "EQUINOX_COSMOS_CONTAINER" "CONSUL KEY" readFromConsul () // TODO add any custom logic preprocessing commandline arguments and/or gathering custom defaults from external sources, etc module Args = From 755d18f4ab683a673ffe1bc4835ea3f273191b80 Mon Sep 17 00:00:00 2001 From: Ruben Bartelink Date: Mon, 13 Apr 2020 03:20:09 +0100 Subject: [PATCH 41/51] Repurpose Program.fs as skeleton host --- equinox-fc/Domain/InventoryTransaction.fs | 49 ---- equinox-fc/Host/Host.fsproj | 9 +- equinox-fc/Host/Program.fs | 278 +++++----------------- 3 files changed, 71 insertions(+), 265 deletions(-) diff --git a/equinox-fc/Domain/InventoryTransaction.fs b/equinox-fc/Domain/InventoryTransaction.fs index a6f8d5bf3..9ab118b6c 100644 --- a/equinox-fc/Domain/InventoryTransaction.fs +++ b/equinox-fc/Domain/InventoryTransaction.fs @@ -152,52 +152,3 @@ module Cosmos = let cacheStrategy = Equinox.Cosmos.CachingStrategy.SlidingWindow (cache, System.TimeSpan.FromMinutes 20.) fun id -> Equinox.Cosmos.Resolver(context, Events.codec, Fold.fold, Fold.initial, cacheStrategy, accessStrategy).Resolve(id, opt) let createService (context, cache) = create (resolver (context, cache)) - -/// Handles requirement to infer when a transaction is 'stuck' -/// Note we don't want to couple to the state in a deep manner; thus we track: -/// a) when the request intent is established (aka when *Requested is logged) -/// b) when a transaction reports that it has Completed -module Watchdog = - - module Events = - - // TOCONSIDER: this can be written more generically by just grabbing the time of the very first event - type Event = - | AdjustmentRequested - | TransferRequested - | Completed - interface TypeShape.UnionContract.IUnionContract - type TimestampAndEvent = System.DateTimeOffset * Event - let codec = - let up (encoded : FsCodec.ITimelineEvent<_>, message) : TimestampAndEvent = encoded.Timestamp, message - let down _union = failwith "Not Implemented" - FsCodec.NewtonsoftJson.Codec.Create(up, down) - - module Fold = - - type State = Initial | Active of startTime: System.DateTimeOffset | Completed - let initial = Initial - let evolve _state = function - | startTime, (Events.AdjustmentRequested | Events.TransferRequested ) -> Active startTime - | _, Events.Completed -> Completed - let fold : State -> Events.TimestampAndEvent seq -> State = Seq.fold evolve - - type Status = Complete | Active | Stuck - let categorize cutoffTime = function - | Fold.Initial -> Active // cover (hypothetical) corner case where we don't have any valid events yet - | Fold.Active startTime when startTime < cutoffTime -> Stuck - | Fold.Active _ -> Active - | Fold.Completed -> Complete - - let fold : Events.TimestampAndEvent seq -> Fold.State = - Fold.fold Fold.initial - - let (|FoldToWatchdogState|) events : Fold.State = - events - |> Seq.choose Events.codec.TryDecode - |> fold - - let (|Match|_|) = function - | FsCodec.StreamName.CategoryAndId (Category, InventoryTransactionId.Parse transId), FoldToWatchdogState state -> - Some (transId, state) - | _ -> None \ No newline at end of file diff --git a/equinox-fc/Host/Host.fsproj b/equinox-fc/Host/Host.fsproj index 02dd50b03..cae31189a 100644 --- a/equinox-fc/Host/Host.fsproj +++ b/equinox-fc/Host/Host.fsproj @@ -1,4 +1,4 @@ - + Exe @@ -13,9 +13,12 @@ - - + + + + + diff --git a/equinox-fc/Host/Program.fs b/equinox-fc/Host/Program.fs index 529d902d2..2047de195 100644 --- a/equinox-fc/Host/Program.fs +++ b/equinox-fc/Host/Program.fs @@ -1,7 +1,10 @@ module Fc.Host.Program -open Propulsion.Cosmos -open Propulsion.EventStore +open Microsoft.AspNetCore +open Microsoft.AspNetCore.Builder +open Microsoft.AspNetCore.Mvc +open Microsoft.Extensions.DependencyInjection +open Microsoft.Extensions.Hosting open Serilog open System @@ -36,129 +39,26 @@ module Args = open Equinox.EventStore [] type Parameters = - | [] ConsumerGroupName of string - | [] MaxReadAhead of int - | [] MaxWriters of int | [] Verbose - | [] VerboseConsole - | [] Es of ParseResults - | [] SrcCosmos of ParseResults + | [] Es of ParseResults + | [] Cosmos of ParseResults interface IArgParserTemplate with member a.Usage = match a with - | ConsumerGroupName _ -> "Projector consumer group name." - | MaxReadAhead _ -> "maximum number of batches to let processing get ahead of completion. Default: 16." - | MaxWriters _ -> "maximum number of concurrent streams on which to process at any time. Default: 64." | Verbose -> "request Verbose Logging. Default: off." - | VerboseConsole -> "request Verbose Console Logging. Default: off." | Es _ -> "specify EventStore input parameters." - | SrcCosmos _ -> "specify CosmosDB input parameters." + | Cosmos _ -> "specify CosmosDB input parameters." and Arguments(a : ParseResults) = - member __.ConsumerGroupName = a.GetResult ConsumerGroupName member __.Verbose = a.Contains Parameters.Verbose - member __.VerboseConsole = a.Contains VerboseConsole - member __.MaxReadAhead = a.GetResult(MaxReadAhead, 16) - member __.MaxConcurrentStreams = a.GetResult(MaxWriters, 64) member __.StatsInterval = TimeSpan.FromMinutes 1. - member val Source : Choice = + member val Source : Choice = match a.TryGetSubCommand() with - | Some (Es es) -> Choice1Of2 (EsSourceArguments es) - | Some (SrcCosmos cosmos) -> Choice2Of2 (CosmosSourceArguments cosmos) + | Some (Es es) -> Choice1Of2 (EsArguments es) + | Some (Cosmos cosmos) -> Choice2Of2 (CosmosArguments cosmos) | _ -> raise (MissingArg "Must specify one of cosmos or es for Src") - member x.SourceParams() : Choice = - match x.Source with - | Choice1Of2 srcE -> - let startPos, cosmos = srcE.StartPos, srcE.Cosmos - Log.Information("Processing Consumer Group {groupName} from {startPos} (force: {forceRestart}) in Database {db} Container {container}", - x.ConsumerGroupName, startPos, srcE.ForceRestart, cosmos.Database, cosmos.Container) - Log.Information("Ingesting in batches of [{minBatchSize}..{batchSize}], reading up to {maxReadAhead} uncommitted batches ahead", - srcE.MinBatchSize, srcE.StartingBatchSize, x.MaxReadAhead) - Choice1Of2 (srcE, cosmos, - { groupName = x.ConsumerGroupName; start = startPos; checkpointInterval = srcE.CheckpointInterval; tailInterval = srcE.TailInterval - forceRestart = srcE.ForceRestart - batchSize = srcE.StartingBatchSize; minBatchSize = srcE.MinBatchSize; gorge = srcE.Gorge; streamReaders = 0 }) - | Choice2Of2 srcC -> - let disco, auxColl = - match srcC.LeaseContainer with - | None -> srcC.Discovery, { database = srcC.Database; container = srcC.Container + "-aux" } - | Some sc -> srcC.Discovery, { database = srcC.Database; container = sc } - Log.Information("Max read backlog: {maxReadAhead}", x.MaxReadAhead) - Log.Information("Processing Lease {leaseId} in Database {db} Container {container} with maximum document count limited to {maxDocuments}", - x.ConsumerGroupName, auxColl.database, auxColl.container, srcC.MaxDocuments) - if srcC.FromTail then Log.Warning("(If new projector group) Skipping projection of all existing events.") - srcC.LagFrequency |> Option.iter (fun s -> Log.Information("Dumping lag stats at {lagS:n0}s intervals", s.TotalSeconds)) - Choice2Of2 (srcC, (disco, auxColl, x.ConsumerGroupName, srcC.FromTail, srcC.MaxDocuments, srcC.LagFrequency)) - and [] CosmosSourceParameters = - | [] FromTail - | [] MaxDocuments of int - | [] LagFreqM of float - | [] LeaseContainer of string - - | [] ConnectionMode of Equinox.Cosmos.ConnectionMode - | [] Connection of string - | [] Database of string - | [] Container of string // Actually Mandatory, but stating that is not supported - | [] Timeout of float - | [] Retries of int - | [] RetriesWaitTime of float - - | [] Cosmos of ParseResults - interface IArgParserTemplate with - member a.Usage = a |> function - | FromTail -> "(iff the Consumer Name is fresh) - force skip to present Position. Default: Never skip an event." - | MaxDocuments _ -> "maximum item count to request from feed. Default: unlimited" - | LagFreqM _ -> "frequency (in minutes) to dump lag stats. Default: off" - | LeaseContainer _ -> "specify Container Name for Leases container. Default: `sourceContainer` + `-aux`." - - | ConnectionMode _ -> "override the connection mode. Default: Direct." - | Connection _ -> "specify a connection string for a Cosmos account. (optional if environment variable EQUINOX_COSMOS_CONNECTION specified)" - | Database _ -> "specify a database name for Cosmos account. (optional if environment variable EQUINOX_COSMOS_DATABASE specified)" - | Container _ -> "specify a container name within `Database`" - | Timeout _ -> "specify operation timeout in seconds. Default: 5." - | Retries _ -> "specify operation retries. Default: 1." - | RetriesWaitTime _ -> "specify max wait-time for retry when being throttled by Cosmos in seconds. Default: 5." - - | Cosmos _ -> "CosmosDb Sink parameters." - and CosmosSourceArguments(a : ParseResults) = - member __.FromTail = a.Contains CosmosSourceParameters.FromTail - member __.MaxDocuments = a.TryGetResult MaxDocuments - member __.LagFrequency = a.TryGetResult LagFreqM |> Option.map TimeSpan.FromMinutes - member __.LeaseContainer = a.TryGetResult CosmosSourceParameters.LeaseContainer - - member __.Mode = a.GetResult(CosmosSourceParameters.ConnectionMode, Equinox.Cosmos.ConnectionMode.Direct) - member __.Discovery = Discovery.FromConnectionString __.Connection - member __.Connection = a.TryGetResult CosmosSourceParameters.Connection |> defaultWithEnvVar "EQUINOX_COSMOS_CONNECTION" "Connection" - member __.Database = a.TryGetResult CosmosSourceParameters.Database |> defaultWithEnvVar "EQUINOX_COSMOS_DATABASE" "Database" - member __.Container = a.GetResult CosmosSourceParameters.Container - member __.Timeout = a.GetResult(CosmosSourceParameters.Timeout, 5.) |> TimeSpan.FromSeconds - member __.Retries = a.GetResult(CosmosSourceParameters.Retries, 1) - member __.MaxRetryWaitTime = a.GetResult(CosmosSourceParameters.RetriesWaitTime, 5.) |> TimeSpan.FromSeconds - member x.BuildConnectionDetails() = - let (Discovery.UriAndKey (endpointUri, _)) as discovery = x.Discovery - Log.Information("Source CosmosDb {mode} {endpointUri} Database {database} Container {container}", - x.Mode, endpointUri, x.Database, x.Container) - Log.Information("Source CosmosDb timeout {timeout}s; Throttling retries {retries}, max wait {maxRetryWaitTime}s", - (let t = x.Timeout in t.TotalSeconds), x.Retries, (let t = x.MaxRetryWaitTime in t.TotalSeconds)) - let c = Equinox.Cosmos.Connector(x.Timeout, x.Retries, x.MaxRetryWaitTime, Log.Logger, mode=x.Mode) - discovery, { database = x.Database; container = x.Container }, c - - member val Cosmos = - match a.TryGetSubCommand() with - | Some (CosmosSourceParameters.Cosmos cosmos) -> CosmosArguments cosmos - | _ -> raise (MissingArg "Must specify cosmos details") - and [] EsSourceParameters = - | [] FromTail - | [] Gorge of int - | [] Tail of intervalS: float - | [] ForceRestart - | [] BatchSize of int - | [] MinBatchSize of int - | [] Position of int64 - | [] Chunk of int - | [] Percent of float - + and [] EsParameters = | [] Verbose | [] Timeout of float | [] Retries of int @@ -168,20 +68,8 @@ module Args = | [] Port of int | [] Username of string | [] Password of string - - | [] Cosmos of ParseResults interface IArgParserTemplate with member a.Usage = a |> function - | FromTail -> "Start the processing from the Tail" - | Gorge _ -> "Request Parallel readers phase during initial catchup, running one chunk (256MB) apart. Default: off" - | Tail _ -> "attempt to read from tail at specified interval in Seconds. Default: 1" - | ForceRestart _ -> "Forget the current committed position; start from (and commit) specified position. Default: start from specified position or resume from committed." - | BatchSize _ -> "maximum item count to request from feed. Default: 4096" - | MinBatchSize _ -> "minimum item count to drop down to in reaction to read failures. Default: 512" - | Position _ -> "EventStore $all Stream Position to commence from" - | Chunk _ -> "EventStore $all Chunk to commence from" - | Percent _ -> "EventStore $all Stream Position to commence from (as a percentage of current tail position)" - | Verbose -> "Include low level Store logging." | Tcp -> "Request connecting direct to a TCP/IP endpoint. Default: Use Clustered mode with Gossip-driven discovery (unless environment variable EQUINOX_ES_TCP specifies 'true')." | Host _ -> "TCP mode: specify a hostname to connect to directly. Clustered mode: use Gossip protocol against all A records returned from DNS query. (optional if environment variable EQUINOX_ES_HOST specified)" @@ -191,22 +79,7 @@ module Args = | Timeout _ -> "specify operation timeout in seconds. Default: 20." | Retries _ -> "specify operation retries. Default: 3." | HeartbeatTimeout _ -> "specify heartbeat timeout in seconds. Default: 1.5." - - | Cosmos _ -> "CosmosDB (Checkpoint) Store parameters." - and EsSourceArguments(a : ParseResults) = - member __.Gorge = a.TryGetResult Gorge - member __.TailInterval = a.GetResult(Tail, 1.) |> TimeSpan.FromSeconds - member __.ForceRestart = a.Contains ForceRestart - member __.StartingBatchSize = a.GetResult(BatchSize, 4096) - member __.MinBatchSize = a.GetResult(MinBatchSize, 512) - member __.StartPos = - match a.TryGetResult Position, a.TryGetResult Chunk, a.TryGetResult Percent, a.Contains EsSourceParameters.FromTail with - | Some p, _, _, _ -> Absolute p - | _, Some c, _, _ -> StartPos.Chunk c - | _, _, Some p, _ -> Percentage p - | None, None, None, true -> StartPos.TailOrCheckpoint - | None, None, None, _ -> StartPos.StartOrCheckpoint - + and EsArguments(a : ParseResults) = member __.Discovery = match __.Tcp, __.Port with | false, None -> Discovery.GossipDns __.Host @@ -214,14 +87,14 @@ module Args = | true, None -> Discovery.Uri (UriBuilder("tcp", __.Host).Uri) | true, Some p -> Discovery.Uri (UriBuilder("tcp", __.Host, p).Uri) member __.Tcp = - a.Contains EsSourceParameters.Tcp + a.Contains EsParameters.Tcp || EnvVar.tryGet "EQUINOX_ES_TCP" |> Option.exists (fun s -> String.Equals(s, bool.TrueString, StringComparison.OrdinalIgnoreCase)) member __.Port = match a.TryGetResult Port with Some x -> Some x | None -> EnvVar.tryGet "EQUINOX_ES_PORT" |> Option.map int member __.Host = a.TryGetResult Host |> defaultWithEnvVar "EQUINOX_ES_HOST" "Host" member __.User = a.TryGetResult Username |> defaultWithEnvVar "EQUINOX_ES_USERNAME" "Username" member __.Password = a.TryGetResult Password |> defaultWithEnvVar "EQUINOX_ES_PASSWORD" "Password" - member __.Retries = a.GetResult(EsSourceParameters.Retries, 3) - member __.Timeout = a.GetResult(EsSourceParameters.Timeout, 20.) |> TimeSpan.FromSeconds + member __.Retries = a.GetResult(EsParameters.Retries, 3) + member __.Timeout = a.GetResult(EsParameters.Timeout, 20.) |> TimeSpan.FromSeconds member __.Heartbeat = a.GetResult(HeartbeatTimeout, 1.5) |> TimeSpan.FromSeconds member x.Connect(log: ILogger, storeLog: ILogger, appName, connectionStrategy) = let s (x : TimeSpan) = x.TotalSeconds @@ -233,12 +106,6 @@ module Args = let tags=["M", Environment.MachineName; "I", Guid.NewGuid() |> string] Connector(x.User, x.Password, x.Timeout, x.Retries, log=log, heartbeatTimeout=x.Heartbeat, tags=tags) .Establish(appName, discovery, connectionStrategy) |> Async.RunSynchronously - - member __.CheckpointInterval = TimeSpan.FromHours 1. - member val Cosmos : CosmosArguments = - match a.TryGetSubCommand() with - | Some (EsSourceParameters.Cosmos cosmos) -> CosmosArguments cosmos - | _ -> raise (MissingArg "Must specify `cosmos` checkpoint store when source is `es`") and [] CosmosParameters = | [] Connection of string | [] ConnectionMode of ConnectionMode @@ -287,6 +154,7 @@ module Logging = LoggerConfiguration() .Destructure.FSharpTypes() .Enrich.FromLogContext() + .MinimumLevel.Override("Microsoft.AspNetCore", Serilog.Events.LogEventLevel.Warning) |> fun c -> if verbose then c.MinimumLevel.Debug() else c |> fun c -> let t = "[{Timestamp:HH:mm:ss} {Level:u3}] {partitionKeyRangeId,2} {Message:lj} {NewLine}{Exception}" c.WriteTo.Console(theme=Sinks.SystemConsole.Themes.AnsiConsoleTheme.Code, outputTemplate=t) @@ -294,48 +162,39 @@ module Logging = let [] AppName = "Host" -module EventStoreContext = +open System - let cache = Equinox.Cache(AppName, sizeMb = 10) - let create connection = Equinox.EventStore.Context(connection, Equinox.EventStore.BatchingPolicy(maxBatchSize=500)) - let tryMapEvent isWhitelisted (x : EventStore.ClientAPI.ResolvedEvent) = - match x.Event with - | e when not e.IsJson || e.EventStreamId.StartsWith "$" || not (isWhitelisted e.EventStreamId) -> None - | PropulsionStreamEvent e -> Some e +/// Defines the Hosting configuration, including registration of the store and backend services +type Startup() = -let transactionStreamPrefix = sprintf "%s-" Fc.Inventory.Transaction.Category -let isTransactionStream : string -> bool = function sn -> sn.StartsWith transactionStreamPrefix + // This method gets called by the runtime. Use this method to add services to the container. + member __.ConfigureServices(services: IServiceCollection) : unit = + services + .AddMvc() + .SetCompatibilityVersion(CompatibilityVersion.Latest) + .AddNewtonsoftJson() // until FsCodec.SystemTextJson is available + |> ignore -let build (args : Args.Arguments) = - match args.SourceParams() with - | Choice1Of2 (srcE, cosmos, spec) -> - let connectEs () = srcE.Connect(Log.Logger, Log.Logger, AppName, Equinox.EventStore.ConnectionStrategy.ClusterSingle Equinox.EventStore.NodePreference.PreferSlave) - let (discovery, database, container, connector) = cosmos.BuildConnectionDetails() + // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. + member __.Configure(app: IApplicationBuilder, env: IHostEnvironment) : unit = + if env.IsDevelopment() then app.UseDeveloperExceptionPage() |> ignore + else app.UseHsts() |> ignore - let connection = connector.Connect(AppName, discovery) |> Async.RunSynchronously - let cache = Equinox.Cache(AppName, sizeMb = 10) - let context = Equinox.Cosmos.Context(connection, database, container) + app.UseHttpsRedirection() + .UseRouting() + .UseSerilogRequestLogging() // see https://nblumhardt.com/2019/10/serilog-in-aspnetcore-3/ + .UseEndpoints(fun endpoints -> endpoints.MapControllers() |> ignore) + |> ignore - let resolveCheckpointStream = - let codec = FsCodec.NewtonsoftJson.Codec.Create() - let caching = Equinox.Cosmos.CachingStrategy.SlidingWindow (cache, TimeSpan.FromMinutes 20.) - let access = Equinox.Cosmos.AccessStrategy.Custom (Checkpoint.Fold.isOrigin, Checkpoint.Fold.transmute) - fun target -> Equinox.Cosmos.Resolver(context, codec, Checkpoint.Fold.fold, Checkpoint.Fold.initial, caching, access).Resolve(target, Equinox.AllowStale) - let checkpoints = Checkpoint.CheckpointSeries(spec.groupName, Log.ForContext(), resolveCheckpointStream) - let handle (_stream, _span) : Async = failwith "TODO" // Handler.handleStreamEvents (Handler.tryHandle driveTransaction) - let sink = - Propulsion.Streams.StreamsProjector.Start( - Log.Logger, args.MaxReadAhead, args.MaxConcurrentStreams, handle, stats = Handler.Stats(Log.Logger)) - let connect () = let c = connectEs () in c.ReadConnection - let runPipeline = - EventStoreSource.Run( - Log.Logger, sink, checkpoints, connect, spec, EventStoreContext.tryMapEvent isTransactionStream, - args.MaxReadAhead, args.StatsInterval) - sink, runPipeline - | Choice2Of2 (srcCosmos, (auxDiscovery, aux, leaseId, startFromTail, maxDocuments, lagFrequency)) -> - let (discovery, database, container, connector) = srcCosmos.Cosmos.BuildConnectionDetails() +let build (args : Args.Arguments) = + let cache = Equinox.Cache(AppName, sizeMb = 10) + match args.Source with + | Choice1Of2 es -> + let connectEs () = es.Connect(Log.Logger, Log.Logger, AppName, Equinox.EventStore.ConnectionStrategy.ClusterSingle Equinox.EventStore.NodePreference.PreferSlave) + failwith "TODO" + | Choice2Of2 cosmos -> + let (discovery, database, container, connector) = cosmos.BuildConnectionDetails() let connection = connector.Connect(AppName, discovery) |> Async.RunSynchronously - let cache = Equinox.Cache(AppName, sizeMb = 10) let context = Equinox.Cosmos.Context(connection, database, container) let inventoryService = let inventoryId = InventoryId.parse "FC000" @@ -354,36 +213,29 @@ let build (args : Args.Arguments) = let maxAttempts = 3 Fc.Location.Cosmos.createService (zeroBalance, toBalanceCarriedForward, shouldClose) (context, cache, maxAttempts) let processor = Fc.Inventory.Processor.Service(transactionService, locations, inventoryService) + failwith "TODO" + // TODO add APIs etc - let handle = Handler.handleStreamEvents (Handler.tryHandle processor.Push) - let sink = - Propulsion.Streams.StreamsProjector.Start( - Log.Logger, args.MaxReadAhead, args.MaxConcurrentStreams, handle, stats = Handler.Stats(Log.Logger)) - let mapToStreamItems (docs : Microsoft.Azure.Documents.Document seq) : Propulsion.Streams.StreamEvent<_> seq = - docs - |> Seq.collect EquinoxCosmosParser.enumStreamEvents - |> Seq.filter (fun e -> e.stream |> FsCodec.StreamName.toString |> isTransactionStream) - let source = { database = database; container = container } - let createObserver () = CosmosSource.CreateObserver(Log.Logger, sink.StartIngester, mapToStreamItems) - let runPipeline = - CosmosSource.Run(Log.Logger, connector.CreateClient(AppName, discovery), source, aux, - leaseId, startFromTail, createObserver, - ?maxDocuments=maxDocuments, ?lagReportFreq=lagFrequency, auxClient=connector.CreateClient(AppName, auxDiscovery)) - sink, runPipeline +open Microsoft.Extensions.DependencyInjection -let run argv = - try let args = Args.parse argv - Logging.initialize args.Verbose args.VerboseConsole - Settings.initialize () - let projector, runSourcePipeline = build args - runSourcePipeline |> Async.Start - projector.AwaitCompletion() |> Async.RunSynchronously - if projector.RanToCompletion then 0 else 2 - with :? Argu.ArguParseException as e -> eprintfn "%s" e.Message; 1 - | Args.MissingArg msg -> eprintfn "%s" msg; 1 - | e -> Log.Fatal(e, "Exiting"); 1 +let run argv args = + let processor = build args + WebHost + .CreateDefaultBuilder(argv) + .UseSerilog() +// .ConfigureServices(fun svc -> svc.AddSingleton(processor)) + .AddStartup() + .Build() + .Run() [] let main argv = - try run argv - finally Log.CloseAndFlush() + try let args = Args.parse argv + try Logging.initialize args.Verbose + try Configuration.initialize () + if run argv args then 0 else 3 + with e when not (e :? Args.MissingArg) -> Log.Fatal(e, "Exiting"); 2 + finally Log.CloseAndFlush() + with Args.MissingArg msg -> eprintfn "%s" msg; 1 + | :? Argu.ArguParseException as e -> eprintfn "%s" e.Message; 1 + | e -> eprintf "Exception %s" e.Message; 1 From f0b6e2a162934e15d860470244f80fc9f885b50b Mon Sep 17 00:00:00 2001 From: Ruben Bartelink Date: Thu, 16 Apr 2020 12:01:42 +0100 Subject: [PATCH 42/51] Add ES wiring --- dotnet-templates.sln | 7 +- equinox-fc/Domain.Tests/LocationTests.fs | 14 ++-- equinox-fc/Domain/Domain.fsproj | 1 + equinox-fc/Domain/Inventory.fs | 9 ++- equinox-fc/Domain/InventoryEpoch.fs | 26 ++++-- equinox-fc/Domain/InventorySeries.fs | 35 +++++--- equinox-fc/Domain/InventoryTransaction.fs | 33 +++++--- equinox-fc/Domain/Location.fs | 11 ++- equinox-fc/Domain/LocationEpoch.fs | 24 ++++-- equinox-fc/Domain/LocationSeries.fs | 28 +++++-- equinox-fc/Fc.sln | 2 +- equinox-fc/{Host => Web}/Program.fs | 79 ++++++++++--------- .../{Host/Host.fsproj => Web/Web.fsproj} | 0 13 files changed, 175 insertions(+), 94 deletions(-) rename equinox-fc/{Host => Web}/Program.fs (82%) rename equinox-fc/{Host/Host.fsproj => Web/Web.fsproj} (100%) diff --git a/dotnet-templates.sln b/dotnet-templates.sln index a8c34bce8..1fd35cfdd 100644 --- a/dotnet-templates.sln +++ b/dotnet-templates.sln @@ -107,7 +107,7 @@ Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Domain", "equinox-fc\Domain EndProject Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Domain.Tests", "equinox-fc\Domain.Tests\Domain.Tests.fsproj", "{49890A45-D6C2-4EF6-87AD-39960E03E254}" EndProject -Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Host", "equinox-fc\Host\Host.fsproj", "{6CB10946-AC56-4DE7-AB65-F6B13B86C703}" +Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Web", "equinox-fc\Web\Web.fsproj", "{A6AA8FAA-0D2C-423D-B166-F4B3AF10C442}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -177,6 +177,10 @@ Global {49890A45-D6C2-4EF6-87AD-39960E03E254}.Debug|Any CPU.Build.0 = Debug|Any CPU {49890A45-D6C2-4EF6-87AD-39960E03E254}.Release|Any CPU.ActiveCfg = Release|Any CPU {49890A45-D6C2-4EF6-87AD-39960E03E254}.Release|Any CPU.Build.0 = Release|Any CPU + {A6AA8FAA-0D2C-423D-B166-F4B3AF10C442}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A6AA8FAA-0D2C-423D-B166-F4B3AF10C442}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A6AA8FAA-0D2C-423D-B166-F4B3AF10C442}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A6AA8FAA-0D2C-423D-B166-F4B3AF10C442}.Release|Any CPU.Build.0 = Release|Any CPU {26BFE6BC-5887-4E40-8CFD-F15332F5A104}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {26BFE6BC-5887-4E40-8CFD-F15332F5A104}.Debug|Any CPU.Build.0 = Debug|Any CPU {26BFE6BC-5887-4E40-8CFD-F15332F5A104}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -222,5 +226,6 @@ Global {5A45EF21-576B-4B40-86BD-F5960ECD66BF} = {DAE9E2B9-EDA2-4064-B0CE-FD5294549374} {83BA87C3-6288-40F4-BC4F-EC3A54586CDF} = {DAE9E2B9-EDA2-4064-B0CE-FD5294549374} {46B8B7C9-3334-4C13-A339-57571C14F2B9} = {4946576F-1558-49ED-A272-6F4D92FB0031} + {A6AA8FAA-0D2C-423D-B166-F4B3AF10C442} = {4946576F-1558-49ED-A272-6F4D92FB0031} EndGlobalSection EndGlobal diff --git a/equinox-fc/Domain.Tests/LocationTests.fs b/equinox-fc/Domain.Tests/LocationTests.fs index 7e2217f7d..ea51855d9 100644 --- a/equinox-fc/Domain.Tests/LocationTests.fs +++ b/equinox-fc/Domain.Tests/LocationTests.fs @@ -6,7 +6,7 @@ open FSharp.UMX open Swensen.Unquote open System -/// Helpers to match `module Cosmos` wrapping inside the impl +/// Helpers to match `module Cosmos/EventStore` wrapping inside the impl module Location = module MemoryStore = @@ -15,17 +15,17 @@ module Location = module Series = - let resolver store = Resolver(store, Series.Events.codec, Series.Fold.fold, Series.Fold.initial).Resolve + let resolve store = Resolver(store, Series.Events.codec, Series.Fold.fold, Series.Fold.initial).Resolve module Epoch = - let resolver store = Resolver(store, Epoch.Events.codec, Epoch.Fold.fold, Epoch.Fold.initial).Resolve + let resolve store = Resolver(store, Epoch.Events.codec, Epoch.Fold.fold, Epoch.Fold.initial).Resolve - let createService (zeroBalance, shouldClose) store = + let create (zeroBalance, toBalanceCarriedForward, shouldClose) store = let maxAttempts = Int32.MaxValue - let series = Series.create (Series.resolver store) maxAttempts - let epochs = Epoch.create (Epoch.resolver store) maxAttempts - create (zeroBalance, shouldClose) (series, epochs) + let series = Series.create (fun (id, _opt) -> Series.resolve store id) maxAttempts + let epochs = Epoch.create (Epoch.resolve store) maxAttempts + create (zeroBalance, toBalanceCarriedForward, shouldClose) (series, epochs) let run (service : Location.Service) (IdsAtLeastOne locations, deltas : _[], transactionId) = Async.RunSynchronously <| async { let runId = mkId () // Need to make making state in store unique when replaying or shrinking diff --git a/equinox-fc/Domain/Domain.fsproj b/equinox-fc/Domain/Domain.fsproj index 9e1499c82..93a753c8b 100644 --- a/equinox-fc/Domain/Domain.fsproj +++ b/equinox-fc/Domain/Domain.fsproj @@ -18,6 +18,7 @@ + diff --git a/equinox-fc/Domain/Inventory.fs b/equinox-fc/Domain/Inventory.fs index 490d7c847..fb747529d 100644 --- a/equinox-fc/Domain/Inventory.fs +++ b/equinox-fc/Domain/Inventory.fs @@ -83,6 +83,13 @@ module Cosmos = let epochs = Epoch.Cosmos.create (context, cache) Helpers.create inventoryId maxTransactionsPerEpoch lookBackLimit (series, epochs) +module EventStore = + + let create inventoryId maxTransactionsPerEpoch lookBackLimit (context, cache) = + let series = Series.EventStore.create (context, cache) + let epochs = Epoch.EventStore.create (context, cache) + Helpers.create inventoryId maxTransactionsPerEpoch lookBackLimit (series, epochs) + module Processor = type Service(transactions : Transaction.Service, locations : Fc.Location.Service, inventory : Service2) = @@ -127,5 +134,5 @@ module Processor = run transactionId (Fc.Inventory.Transaction.Events.TransferRequested { source = source; destination = destination; quantity = quantity }) /// Used by Watchdog to force conclusion of a transaction whose progress has stalled - member __.Push(transactionId) = async { + member __.Drive(transactionId) = async { let! _ = execute transactionId None in () } \ No newline at end of file diff --git a/equinox-fc/Domain/InventoryEpoch.fs b/equinox-fc/Domain/InventoryEpoch.fs index 9942b5f2e..6f3e27954 100644 --- a/equinox-fc/Domain/InventoryEpoch.fs +++ b/equinox-fc/Domain/InventoryEpoch.fs @@ -81,16 +81,28 @@ type Service internal (resolve : InventoryId * InventoryEpochId -> Equinox.Strea let stream = resolve (inventoryId, epochId) stream.Transact(decideSync capacity events) -let create resolver = +let create resolve = let resolve ids = - let stream = resolver (streamName ids) + let stream = resolve (streamName ids) Equinox.Stream(Serilog.Log.ForContext(), stream, maxAttempts = 2) Service(resolve) module Cosmos = - let accessStrategy = Equinox.Cosmos.AccessStrategy.Snapshot (Fold.isOrigin, Fold.snapshot) - let resolver (context, cache) = - let cacheStrategy = Equinox.Cosmos.CachingStrategy.SlidingWindow (cache, System.TimeSpan.FromMinutes 20.) - Equinox.Cosmos.Resolver(context, Events.codec, Fold.fold, Fold.initial, cacheStrategy, accessStrategy).Resolve - let create (context, cache) = create (resolver (context, cache)) + open Equinox.Cosmos + + let accessStrategy = AccessStrategy.Snapshot (Fold.isOrigin, Fold.snapshot) + let create (context, cache) = + let cacheStrategy = CachingStrategy.SlidingWindow (cache, System.TimeSpan.FromMinutes 20.) + let resolver = Resolver(context, Events.codec, Fold.fold, Fold.initial, cacheStrategy, accessStrategy) + create resolver.Resolve + +module EventStore = + + open Equinox.EventStore + + // No use of RollingSnapshots as we're intentionally making an epoch short enough to simply read any time + let create (context, cache) = + let cacheStrategy = CachingStrategy.SlidingWindow (cache, System.TimeSpan.FromMinutes 20.) + let resolver = Resolver(context, Events.codec, Fold.fold, Fold.initial, cacheStrategy) + create resolver.Resolve \ No newline at end of file diff --git a/equinox-fc/Domain/InventorySeries.fs b/equinox-fc/Domain/InventorySeries.fs index 011f24fee..93962680f 100644 --- a/equinox-fc/Domain/InventorySeries.fs +++ b/equinox-fc/Domain/InventorySeries.fs @@ -40,21 +40,32 @@ type Service internal (resolve : InventoryId -> Equinox.Stream(), stream, maxAttempts = 2) - Service (resolve) + Service(resolve) module Cosmos = - let accessStrategy = Equinox.Cosmos.AccessStrategy.LatestKnownEvent - let resolver (context, cache) = - let cacheStrategy = Equinox.Cosmos.CachingStrategy.SlidingWindow (cache, System.TimeSpan.FromMinutes 20.) - // For this stream, we uniformly use stale reads as: - // a) we don't require any information from competing writers - // b) while there are competing writers [which might cause us to have to retry a Transact], this should be infrequent - let opt = Equinox.ResolveOption.AllowStale - fun id -> Equinox.Cosmos.Resolver(context, Events.codec, Fold.fold, Fold.initial, cacheStrategy, accessStrategy).Resolve(id, opt) + open Equinox.Cosmos + + let accessStrategy = AccessStrategy.LatestKnownEvent + let create (context, cache) = + let cacheStrategy = CachingStrategy.SlidingWindow (cache, System.TimeSpan.FromMinutes 20.) + let resolver = Resolver(context, Events.codec, Fold.fold, Fold.initial, cacheStrategy, accessStrategy) + create <| fun (id, opt) -> resolver.Resolve(id, opt) + +module EventStore = + + open Equinox.EventStore + + let accessStrategy = AccessStrategy.LatestKnownEvent let create (context, cache) = - create (resolver (context, cache)) + let cacheStrategy = CachingStrategy.SlidingWindow (cache, System.TimeSpan.FromMinutes 20.) + let resolver = Resolver(context, Events.codec, Fold.fold, Fold.initial, cacheStrategy, accessStrategy) + create <| fun (id, opt) -> resolver.Resolve(id, opt) diff --git a/equinox-fc/Domain/InventoryTransaction.fs b/equinox-fc/Domain/InventoryTransaction.fs index 9ab118b6c..a775a4715 100644 --- a/equinox-fc/Domain/InventoryTransaction.fs +++ b/equinox-fc/Domain/InventoryTransaction.fs @@ -111,7 +111,7 @@ module Fold = [event] | _ -> [] - /// Determines the next action (if any) to be carried out in this workflwo + /// Determines the next action (if any) to be carried out in this workflow let nextAction : State -> Action = function | Initial -> failwith "Cannot interpret Initial state" | Running (Adjust r) -> Action.Adjust (r.location, r.quantity) @@ -136,19 +136,30 @@ type Service internal (resolve : InventoryTransactionId -> Equinox.Stream(), stream, maxAttempts = 2) - Service (resolve) + Service(resolve) module Cosmos = + open Equinox.Cosmos + // in the happy path case, the event stream will typically be short, and the state cached, so snapshotting is less critical - let accessStrategy = Equinox.Cosmos.AccessStrategy.Unoptimized - // ... and there will generally be a single actor touching it at a given time, so we don't need to do a load (which would be more expensive than normal given the `accessStrategy`) before we sync - let opt = Equinox.AllowStale - let resolver (context, cache) = - let cacheStrategy = Equinox.Cosmos.CachingStrategy.SlidingWindow (cache, System.TimeSpan.FromMinutes 20.) - fun id -> Equinox.Cosmos.Resolver(context, Events.codec, Fold.fold, Fold.initial, cacheStrategy, accessStrategy).Resolve(id, opt) - let createService (context, cache) = create (resolver (context, cache)) + let accessStrategy = AccessStrategy.Unoptimized + let create (context, cache) = + let cacheStrategy = CachingStrategy.SlidingWindow (cache, System.TimeSpan.FromMinutes 20.) + let resolver = Resolver(context, Events.codec, Fold.fold, Fold.initial, cacheStrategy, accessStrategy) + create <| fun (id, opt) -> resolver.Resolve(id, opt) + +module EventStore = + + open Equinox.EventStore + + let create (context, cache) = + let cacheStrategy = CachingStrategy.SlidingWindow (cache, System.TimeSpan.FromMinutes 20.) + let resolver = Resolver(context, Events.codec, Fold.fold, Fold.initial, cacheStrategy) + create <| fun (id, opt) -> resolver.Resolve(id, opt) diff --git a/equinox-fc/Domain/Location.fs b/equinox-fc/Domain/Location.fs index 37c08479d..ccef4427a 100644 --- a/equinox-fc/Domain/Location.fs +++ b/equinox-fc/Domain/Location.fs @@ -42,7 +42,14 @@ module Helpers = module Cosmos = - let createService (zeroBalance, toBalanceCarriedForward, shouldClose) (context, cache, maxAttempts) = - let series = Series.Cosmos.createService (context, cache, maxAttempts) + let create (zeroBalance, toBalanceCarriedForward, shouldClose) (context, cache, maxAttempts) = + let series = Series.Cosmos.create (context, cache, maxAttempts) let epochs = Epoch.Cosmos.create (context, cache, maxAttempts) create (zeroBalance, toBalanceCarriedForward, shouldClose) (series, epochs) + +module EventStore = + + let create (zeroBalance, toBalanceCarriedForward, shouldClose) (context, cache, maxAttempts) = + let series = Series.EventStore.create (context, cache, maxAttempts) + let epochs = Epoch.EventStore.create (context, cache, maxAttempts) + create (zeroBalance, toBalanceCarriedForward, shouldClose) (series, epochs) diff --git a/equinox-fc/Domain/LocationEpoch.fs b/equinox-fc/Domain/LocationEpoch.fs index 330e3dd2d..5a17268e2 100644 --- a/equinox-fc/Domain/LocationEpoch.fs +++ b/equinox-fc/Domain/LocationEpoch.fs @@ -133,17 +133,27 @@ type Service internal (resolve : LocationId * LocationEpochId -> Equinox.Stream< let stream = resolve (locationId, epochId) stream.Transact(sync prevEpochBalanceCarriedForward decide shouldClose) -let create resolver maxAttempts = +let create resolve maxAttempts = let resolve locationId = - let stream = resolver (streamName locationId) + let stream = resolve (streamName locationId) Equinox.Stream(Serilog.Log.ForContext(), stream, maxAttempts = maxAttempts) Service (resolve) module Cosmos = - let accessStrategy = Equinox.Cosmos.AccessStrategy.Unoptimized - let resolver (context, cache) = - let cacheStrategy = Equinox.Cosmos.CachingStrategy.SlidingWindow (cache, System.TimeSpan.FromMinutes 20.) - Equinox.Cosmos.Resolver(context, Events.codec, Fold.fold, Fold.initial, cacheStrategy, accessStrategy).Resolve + open Equinox.Cosmos + + let accessStrategy = AccessStrategy.Unoptimized + let create (context, cache, maxAttempts) = + let cacheStrategy = CachingStrategy.SlidingWindow (cache, System.TimeSpan.FromMinutes 20.) + let resolver = Resolver(context, Events.codec, Fold.fold, Fold.initial, cacheStrategy, accessStrategy) + create resolver.Resolve maxAttempts + +module EventStore = + + open Equinox.EventStore + let create (context, cache, maxAttempts) = - create (resolver (context, cache)) maxAttempts + let cacheStrategy = CachingStrategy.SlidingWindow (cache, System.TimeSpan.FromMinutes 20.) + let resolver = Resolver(context, Events.codec, Fold.fold, Fold.initial, cacheStrategy) + create resolver.Resolve maxAttempts diff --git a/equinox-fc/Domain/LocationSeries.fs b/equinox-fc/Domain/LocationSeries.fs index e113a7c67..53df26280 100644 --- a/equinox-fc/Domain/LocationSeries.fs +++ b/equinox-fc/Domain/LocationSeries.fs @@ -37,19 +37,31 @@ type Service internal (resolve : LocationId -> Equinox.Stream(), stream, maxAttempts = maxAttempts) - Service (resolve) + Service(resolve) module Cosmos = open Equinox.Cosmos - let resolver (context, cache) = + let accessStrategy = AccessStrategy.LatestKnownEvent + let create (context, cache, maxAttempts) = let cacheStrategy = CachingStrategy.SlidingWindow (cache, System.TimeSpan.FromMinutes 20.) - let opt = Equinox.ResolveOption.AllowStale - fun id -> Resolver(context, Events.codec, Fold.fold, Fold.initial, cacheStrategy, AccessStrategy.LatestKnownEvent).Resolve(id, opt) - let createService (context, cache, maxAttempts) = - create (resolver (context, cache)) maxAttempts + let resolver = Resolver(context, Events.codec, Fold.fold, Fold.initial, cacheStrategy, accessStrategy) + let resolve (id, opt) = resolver.Resolve(id, opt) + create resolve maxAttempts + +module EventStore = + + open Equinox.EventStore + + let accessStrategy = AccessStrategy.LatestKnownEvent + let create (context, cache, maxAttempts) = + let cacheStrategy = CachingStrategy.SlidingWindow (cache, System.TimeSpan.FromMinutes 20.) + let resolver = Resolver(context, Events.codec, Fold.fold, Fold.initial, cacheStrategy, accessStrategy) + let resolve (id, opt) = resolver.Resolve(id, opt) + create resolve maxAttempts diff --git a/equinox-fc/Fc.sln b/equinox-fc/Fc.sln index 7496986dc..5df8389a2 100644 --- a/equinox-fc/Fc.sln +++ b/equinox-fc/Fc.sln @@ -12,7 +12,7 @@ ProjectSection(SolutionItems) = preProject README.md = README.md EndProjectSection EndProject -Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Watchdog", "Watchdog\Watchdog.fsproj", "{B57DFA65-DC4F-4673-A5DC-7B00A08EFFF0}" +Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Web", "Web\Web.fsproj", "{B57DFA65-DC4F-4673-A5DC-7B00A08EFFF0}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution diff --git a/equinox-fc/Host/Program.fs b/equinox-fc/Web/Program.fs similarity index 82% rename from equinox-fc/Host/Program.fs rename to equinox-fc/Web/Program.fs index 2047de195..bd3be1375 100644 --- a/equinox-fc/Host/Program.fs +++ b/equinox-fc/Web/Program.fs @@ -1,7 +1,8 @@ -module Fc.Host.Program +module Fc.Web.Program open Microsoft.AspNetCore open Microsoft.AspNetCore.Builder +open Microsoft.AspNetCore.Hosting open Microsoft.AspNetCore.Mvc open Microsoft.Extensions.DependencyInjection open Microsoft.Extensions.Hosting @@ -34,6 +35,7 @@ module Args = let private defaultWithEnvVar varName argName = function | None -> getEnvVarForArgumentOrThrow varName argName | Some x -> x + let isEnvVarTrue varName = EnvVar.tryGet varName |> Option.exists (fun s -> String.Equals(s, bool.TrueString, StringComparison.OrdinalIgnoreCase)) open Argu open Equinox.Cosmos open Equinox.EventStore @@ -86,9 +88,7 @@ module Args = | false, Some p -> Discovery.GossipDnsCustomPort (__.Host, p) | true, None -> Discovery.Uri (UriBuilder("tcp", __.Host).Uri) | true, Some p -> Discovery.Uri (UriBuilder("tcp", __.Host, p).Uri) - member __.Tcp = - a.Contains EsParameters.Tcp - || EnvVar.tryGet "EQUINOX_ES_TCP" |> Option.exists (fun s -> String.Equals(s, bool.TrueString, StringComparison.OrdinalIgnoreCase)) + member __.Tcp = a.Contains Tcp || isEnvVarTrue "EQUINOX_ES_TCP" member __.Port = match a.TryGetResult Port with Some x -> Some x | None -> EnvVar.tryGet "EQUINOX_ES_PORT" |> Option.map int member __.Host = a.TryGetResult Host |> defaultWithEnvVar "EQUINOX_ES_HOST" "Host" member __.User = a.TryGetResult Username |> defaultWithEnvVar "EQUINOX_ES_USERNAME" "Username" @@ -162,8 +162,6 @@ module Logging = let [] AppName = "Host" -open System - /// Defines the Hosting configuration, including registration of the store and backend services type Startup() = @@ -188,43 +186,49 @@ type Startup() = let build (args : Args.Arguments) = let cache = Equinox.Cache(AppName, sizeMb = 10) - match args.Source with - | Choice1Of2 es -> - let connectEs () = es.Connect(Log.Logger, Log.Logger, AppName, Equinox.EventStore.ConnectionStrategy.ClusterSingle Equinox.EventStore.NodePreference.PreferSlave) - failwith "TODO" - | Choice2Of2 cosmos -> - let (discovery, database, container, connector) = cosmos.BuildConnectionDetails() - let connection = connector.Connect(AppName, discovery) |> Async.RunSynchronously - let context = Equinox.Cosmos.Context(connection, database, container) - let inventoryService = - let inventoryId = InventoryId.parse "FC000" - let maxTransactionsPerEpoch = 100 - let lookBackLimit = 10 - Fc.Inventory.Cosmos.create inventoryId maxTransactionsPerEpoch lookBackLimit (context, cache) - let transactionService = Fc.Inventory.Transaction.Cosmos.createService (context, cache) - let locations = - let zeroBalance : Fc.Location.Epoch.Events.CarriedForward = { initial = 0; recentTransactions = [||] } - let chooseTransactionIds = function - | Fc.Location.Epoch.Fold.Init { recentTransactions = ids } -> Seq.ofArray ids - | Fc.Location.Epoch.Fold.Step { id = id } -> Seq.singleton id - let toBalanceCarriedForward (Fc.Location.Epoch.Fold.Current cur as records) : Fc.Location.Epoch.Events.CarriedForward = - { initial = cur; recentTransactions = records |> Seq.collect chooseTransactionIds |> Seq.truncate 5 |> Seq.toArray } - let shouldClose x = false - let maxAttempts = 3 - Fc.Location.Cosmos.createService (zeroBalance, toBalanceCarriedForward, shouldClose) (context, cache, maxAttempts) - let processor = Fc.Inventory.Processor.Service(transactionService, locations, inventoryService) - failwith "TODO" - // TODO add APIs etc -open Microsoft.Extensions.DependencyInjection + let inventoryId = InventoryId.parse "FC000" + let maxTransactionsPerEpoch = 100 + let lookBackLimit = 10 + + let zeroBalance : Fc.Location.Epoch.Events.CarriedForward = { initial = 0; recentTransactions = [||] } + let chooseTransactionIds = function + | Fc.Location.Epoch.Fold.Init { recentTransactions = ids } -> Seq.ofArray ids + | Fc.Location.Epoch.Fold.Step { id = id } -> Seq.singleton id + let toBalanceCarriedForward (Fc.Location.Epoch.Fold.Current cur as records) : Fc.Location.Epoch.Events.CarriedForward = + { initial = cur; recentTransactions = records |> Seq.collect chooseTransactionIds |> Seq.truncate 5 |> Seq.toArray } + let shouldClose x = false + let maxAttempts = 3 + + let transactions, locations, inventory = + match args.Source with + | Choice1Of2 es -> + let connection = es.Connect(Log.Logger, Log.Logger, AppName, Equinox.EventStore.ConnectionStrategy.ClusterSingle Equinox.EventStore.NodePreference.Master) + let context = Equinox.EventStore.Context(connection, Equinox.EventStore.BatchingPolicy(maxBatchSize = 500)) + + let transactions = Fc.Inventory.Transaction.EventStore.create (context, cache) + let locations = Fc.Location.EventStore.create (zeroBalance, toBalanceCarriedForward, shouldClose) (context, cache, maxAttempts) + let inventory = Fc.Inventory.EventStore.create inventoryId maxTransactionsPerEpoch lookBackLimit (context, cache) + transactions, locations, inventory + | Choice2Of2 cosmos -> + let (discovery, database, container, connector) = cosmos.BuildConnectionDetails() + let connection = connector.Connect(AppName, discovery) |> Async.RunSynchronously + let context = Equinox.Cosmos.Context(connection, database, container) + + let transactions = Fc.Inventory.Transaction.Cosmos.create (context, cache) + let locations = Fc.Location.Cosmos.create (zeroBalance, toBalanceCarriedForward, shouldClose) (context, cache, maxAttempts) + let inventory = Fc.Inventory.Cosmos.create inventoryId maxTransactionsPerEpoch lookBackLimit (context, cache) + transactions, locations, inventory + let processor = Fc.Inventory.Processor.Service(transactions, locations, inventory) + processor let run argv args = let processor = build args WebHost .CreateDefaultBuilder(argv) .UseSerilog() -// .ConfigureServices(fun svc -> svc.AddSingleton(processor)) - .AddStartup() + .ConfigureServices(fun svc -> svc.AddSingleton(processor) |> ignore) + .UseStartup() .Build() .Run() @@ -233,7 +237,8 @@ let main argv = try let args = Args.parse argv try Logging.initialize args.Verbose try Configuration.initialize () - if run argv args then 0 else 3 + run argv args + 0 with e when not (e :? Args.MissingArg) -> Log.Fatal(e, "Exiting"); 2 finally Log.CloseAndFlush() with Args.MissingArg msg -> eprintfn "%s" msg; 1 diff --git a/equinox-fc/Host/Host.fsproj b/equinox-fc/Web/Web.fsproj similarity index 100% rename from equinox-fc/Host/Host.fsproj rename to equinox-fc/Web/Web.fsproj From 941032f700743c8095e5fc0ea99f5e7ba34e6c15 Mon Sep 17 00:00:00 2001 From: Ruben Bartelink Date: Mon, 8 Jun 2020 11:24:56 +0100 Subject: [PATCH 43/51] Casing --- dotnet-templates.sln | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet-templates.sln b/dotnet-templates.sln index 1fd35cfdd..a38acd3b8 100644 --- a/dotnet-templates.sln +++ b/dotnet-templates.sln @@ -97,7 +97,7 @@ Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Domain.Tests", "equinox-shi EndProject Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Watchdog.Integration", "equinox-shipping\Watchdog.Integration\Watchdog.Integration.fsproj", "{83BA87C3-6288-40F4-BC4F-EC3A54586CDF}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "equinox-fc", "equinox-fc", "{4946576F-1558-49ED-A272-6F4D92FB0031}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "eqxFc", "eqxFc", "{4946576F-1558-49ED-A272-6F4D92FB0031}" ProjectSection(SolutionItems) = preProject equinox-fc\.template.config\template.json = equinox-fc\.template.config\template.json equinox-fc\README.md = equinox-fc\README.md From 3a2dfdce28fda1dcf43e8639b6c3e47ecc39b596 Mon Sep 17 00:00:00 2001 From: Ruben Bartelink Date: Thu, 11 Jun 2020 13:20:05 +0100 Subject: [PATCH 44/51] Cleanup --- equinox-fc/Domain.Tests/Domain.Tests.fsproj | 2 +- equinox-fc/Domain.Tests/Fixtures.fs | 53 +++++++++ equinox-fc/Domain.Tests/Infrastructure.fs | 51 --------- equinox-fc/Domain.Tests/LocationEpochTests.fs | 26 +++-- .../Domain.Tests/LocationSeriesTests.fs | 5 +- equinox-fc/Domain.Tests/LocationTests.fs | 33 +++--- equinox-fc/Domain/Domain.fsproj | 3 +- equinox-fc/Domain/Infrastructure.fs | 7 -- equinox-fc/Domain/Inventory.fs | 102 +++++++----------- equinox-fc/Domain/InventoryEpoch.fs | 58 +++------- equinox-fc/Domain/InventorySeries.fs | 71 ------------ equinox-fc/Domain/InventoryTransaction.fs | 13 +-- equinox-fc/Domain/Location.fs | 9 +- equinox-fc/Domain/LocationEpoch.fs | 30 +++--- equinox-fc/Domain/LocationSeries.fs | 13 +-- equinox-fc/Web/Program.fs | 82 ++++---------- equinox-fc/Web/Web.fsproj | 1 - 17 files changed, 175 insertions(+), 384 deletions(-) create mode 100644 equinox-fc/Domain.Tests/Fixtures.fs delete mode 100644 equinox-fc/Domain.Tests/Infrastructure.fs delete mode 100644 equinox-fc/Domain/InventorySeries.fs diff --git a/equinox-fc/Domain.Tests/Domain.Tests.fsproj b/equinox-fc/Domain.Tests/Domain.Tests.fsproj index a8213b6eb..636c4384e 100644 --- a/equinox-fc/Domain.Tests/Domain.Tests.fsproj +++ b/equinox-fc/Domain.Tests/Domain.Tests.fsproj @@ -8,7 +8,7 @@ - + diff --git a/equinox-fc/Domain.Tests/Fixtures.fs b/equinox-fc/Domain.Tests/Fixtures.fs new file mode 100644 index 000000000..58dbe1e3e --- /dev/null +++ b/equinox-fc/Domain.Tests/Fixtures.fs @@ -0,0 +1,53 @@ +[] +module Fc.Domain.Tests.Fixtures + +open Serilog +open System + +module EnvVar = + + let tryGet k = Environment.GetEnvironmentVariable k |> Option.ofObj + +module EventStore = + + open Equinox.EventStore + let connect () = + match EnvVar.tryGet "EQUINOX_ES_HOST", EnvVar.tryGet "EQUINOX_ES_USERNAME", EnvVar.tryGet "EQUINOX_ES_PASSWORD" with + | Some h, Some u, Some p -> + let appName = "Domain.Tests" + let discovery = Discovery.GossipDns h + let connector = Connector(u, p, TimeSpan.FromSeconds 5., 5, Logger.SerilogNormal Serilog.Log.Logger) + let connection = connector.Establish(appName, discovery, ConnectionStrategy.ClusterSingle NodePreference.Master) |> Async.RunSynchronously + let context = Context(connection, BatchingPolicy(500)) + let cache = Equinox.Cache (appName, 10) + context, cache + | h, u, p -> + failwithf "Host, Username and Password EQUINOX_ES_* Environment variables are required (%b,%b,%b)" + (Option.isSome h) (Option.isSome u) (Option.isSome p) + +module TestOutputLogger = + + /// Adapts the XUnit ITestOutputHelper to be a Serilog Sink + type TestOutputAdapter(testOutput : Xunit.Abstractions.ITestOutputHelper) = + let template = "{Timestamp:HH:mm:ss.fff zzz} [{Level:u3}] {Message} {Properties}{NewLine}{Exception}" + let formatter = Serilog.Formatting.Display.MessageTemplateTextFormatter(template, null); + let writeSerilogEvent logEvent = + use writer = new System.IO.StringWriter() + formatter.Format(logEvent, writer) + let messageLine = string writer + testOutput.WriteLine messageLine + System.Diagnostics.Debug.Write messageLine + interface Serilog.Core.ILogEventSink with member __.Emit logEvent = writeSerilogEvent logEvent + + let create output = + let logger = TestOutputAdapter output + LoggerConfiguration().Destructure.FSharpTypes().WriteTo.Sink(logger).CreateLogger() + +(* Generic FsCheck helpers *) + +let (|Id|) (x : Guid) = x.ToString "N" |> FSharp.UMX.UMX.tag +let inline mkId () = Guid.NewGuid() |> (|Id|) +let (|Ids|) (xs : Guid[]) = xs |> Array.map (|Id|) +let (|IdsAtLeastOne|) (Ids xs, Id x) = [| yield x; yield! xs |] +let (|AtLeastOne|) (x, xs) = x::xs + diff --git a/equinox-fc/Domain.Tests/Infrastructure.fs b/equinox-fc/Domain.Tests/Infrastructure.fs deleted file mode 100644 index 1c6c0b920..000000000 --- a/equinox-fc/Domain.Tests/Infrastructure.fs +++ /dev/null @@ -1,51 +0,0 @@ -[] -module Fc.Infrastructure - -open Serilog -open System - -let (|Id|) (x : Guid) = x.ToString "N" |> FSharp.UMX.UMX.tag -let inline mkId () = Guid.NewGuid() |> (|Id|) -let (|Ids|) (xs : Guid[]) = xs |> Array.map (|Id|) -let (|IdsAtLeastOne|) (Id x, Ids xs) = Seq.append xs (Seq.singleton x) |> Seq.toArray - -module EnvVar = - - let tryGet k = Environment.GetEnvironmentVariable k |> Option.ofObj - -module Cosmos = - - open Equinox.Cosmos - let connect () = - match EnvVar.tryGet "EQUINOX_COSMOS_CONNECTION", EnvVar.tryGet "EQUINOX_COSMOS_DATABASE", EnvVar.tryGet "EQUINOX_COSMOS_CONTAINER" with - | Some s, Some d, Some c -> - let appName = "Domain.Tests" - let discovery = Discovery.FromConnectionString s - let connector = Connector(TimeSpan.FromSeconds 5., 5, TimeSpan.FromSeconds 5., Serilog.Log.Logger) - let connection = connector.Connect(appName, discovery) |> Async.RunSynchronously - let context = Context(connection, d, c) - let cache = Equinox.Cache (appName, 10) - context, cache - | s, d, c -> - failwithf "Connection, Database and Container EQUINOX_COSMOS_* Environment variables are required (%b,%b,%b)" - (Option.isSome s) (Option.isSome d) (Option.isSome c) - -/// Adapts the XUnit ITestOutputHelper to be a Serilog Sink -type TestOutputAdapter(testOutput : Xunit.Abstractions.ITestOutputHelper) = - let template = "{Timestamp:HH:mm:ss.fff zzz} [{Level:u3}] {Message} {Properties}{NewLine}{Exception}" - let formatter = Serilog.Formatting.Display.MessageTemplateTextFormatter(template, null); - let writeSerilogEvent logEvent = - use writer = new System.IO.StringWriter() - formatter.Format(logEvent, writer) - let messageLine = string writer - testOutput.WriteLine messageLine - System.Diagnostics.Debug.Write messageLine - interface Serilog.Core.ILogEventSink with member __.Emit logEvent = writeSerilogEvent logEvent - -/// Creates a Serilog Log chain emitting to the cited Sink (only) -let createLogger sink = - Serilog.LoggerConfiguration() -// .MinimumLevel.Debug() - .Destructure.FSharpTypes() - .WriteTo.Sink(sink) - .CreateLogger() diff --git a/equinox-fc/Domain.Tests/LocationEpochTests.fs b/equinox-fc/Domain.Tests/LocationEpochTests.fs index 4184d9d13..9de8ec45d 100644 --- a/equinox-fc/Domain.Tests/LocationEpochTests.fs +++ b/equinox-fc/Domain.Tests/LocationEpochTests.fs @@ -1,22 +1,19 @@ -module Fc.LocationEpochTests +module Fc.Domain.Tests.LocationEpochTests open FsCheck.Xunit -open Location.Epoch open Swensen.Unquote -let interpret transactionId delta _balance = +open Fc.Domain.Location.Epoch + +let decide transactionId delta _balance = match delta with | 0 -> (), [] | delta when delta < 0 -> (), [Events.Removed {| delta = -delta; id = transactionId |}] | delta -> (), [Events.Added {| delta = delta; id = transactionId |}] -let validateAndInterpret transactionId expectedBalance delta balance = - test <@ expectedBalance = balance @> - interpret transactionId delta balance - let verifyDeltaEvent transactionId delta events = let dEvents = events |> List.filter (function Events.Added _ | Events.Removed _ -> true | _ -> false) - test <@ interpret transactionId delta (Unchecked.defaultof<_>) = ((), dEvents) @> + test <@ decide transactionId delta (Unchecked.defaultof<_>) = ((), dEvents) @> let [] properties transactionId carriedForward delta1 closeImmediately delta2 close = @@ -24,24 +21,25 @@ let [] properties transactionId carriedForward delta1 closeImmediately let initialShouldClose _state = closeImmediately let res, events = - sync (Some carriedForward) (validateAndInterpret transactionId carriedForward delta1) initialShouldClose Fold.initial - let cfEvents events = events |> List.filter (function Events.CarriedForward _ -> true | _ -> false) + sync (Some carriedForward) (decide transactionId delta1) initialShouldClose Fold.initial + let cfEvents events = events |> List.choose (function Events.CarriedForward e -> Some e | _ -> None) let closeEvents events = events |> List.filter (function Events.Closed -> true | _ -> false) let state1 = Fold.fold Fold.initial events let expectedBalance = carriedForward.initial + delta1 // Only expect closing if it was requested let expectImmediateClose = closeImmediately + let (Fold.Current bal) = res.history test <@ Option.isSome res.result - && expectedBalance = res.balance @> - test <@ [Events.CarriedForward { initial = carriedForward }] = cfEvents events + && expectedBalance = bal @> + test <@ carriedForward = List.head (cfEvents events) && (not expectImmediateClose || 1 = Seq.length (closeEvents events)) @> verifyDeltaEvent transactionId delta1 events (* After initializing, validate we don't need to supply a carriedForward, and don't produce a CarriedForward event *) let shouldClose _state = close - let { isOpen = isOpen; result = worked; balance = bal }, events = - sync None (validateAndInterpret transactionId expectedBalance delta2) shouldClose state1 + let { isOpen = isOpen; result = worked; history = (Fold.Current bal) }, events = + sync None (decide transactionId delta2) shouldClose state1 let expectedBalance = if expectImmediateClose then expectedBalance else expectedBalance + delta2 test <@ [] = cfEvents events && (expectImmediateClose || not close || 1 = Seq.length (closeEvents events)) @> diff --git a/equinox-fc/Domain.Tests/LocationSeriesTests.fs b/equinox-fc/Domain.Tests/LocationSeriesTests.fs index 65f682545..fb59a5e85 100644 --- a/equinox-fc/Domain.Tests/LocationSeriesTests.fs +++ b/equinox-fc/Domain.Tests/LocationSeriesTests.fs @@ -1,9 +1,10 @@ -module Fc.LocationSeriesTests +module Fc.Domain.Tests.LocationSeriesTests open FsCheck.Xunit open FSharp.UMX open Swensen.Unquote -open Location.Series + +open Fc.Domain.Location.Series let [] properties c1 c2 = let events = interpretAdvanceIngestionEpoch c1 Fold.initial diff --git a/equinox-fc/Domain.Tests/LocationTests.fs b/equinox-fc/Domain.Tests/LocationTests.fs index ea51855d9..ad543407f 100644 --- a/equinox-fc/Domain.Tests/LocationTests.fs +++ b/equinox-fc/Domain.Tests/LocationTests.fs @@ -1,11 +1,12 @@ -module Fc.LocationTests +module Fc.Domain.Tests.LocationTests -open Fc.Location open FsCheck.Xunit open FSharp.UMX open Swensen.Unquote open System +open Fc.Domain.Location + /// Helpers to match `module Cosmos/EventStore` wrapping inside the impl module Location = @@ -27,7 +28,7 @@ module Location = let epochs = Epoch.create (Epoch.resolve store) maxAttempts create (zeroBalance, toBalanceCarriedForward, shouldClose) (series, epochs) -let run (service : Location.Service) (IdsAtLeastOne locations, deltas : _[], transactionId) = Async.RunSynchronously <| async { +let run (service : Fc.Domain.Location.Service) (IdsAtLeastOne locations, deltas : _[], transactionId) = Async.RunSynchronously <| async { let runId = mkId () // Need to make making state in store unique when replaying or shrinking let locations = locations |> Array.map (fun x -> % (sprintf "%O/%O" x runId)) @@ -35,37 +36,35 @@ let run (service : Location.Service) (IdsAtLeastOne locations, deltas : _[], tra (* Apply random deltas *) - let adjust delta (bal : Epoch.Fold.Balance) = + let adjust delta (state : Epoch.Fold.State) = + let (Epoch.Fold.Balance bal) = state let value = max -bal delta if value = 0 then 0, [] elif value < 0 then value, [Epoch.Events.Removed {| delta = -value; id = transactionId |}] else value, [Epoch.Events.Added {| delta = value; id = transactionId |}] - let! appliedDeltas = seq { for loc, x in updates -> async { let! _, eff = service.Execute(loc, adjust x) in return loc,eff } } |> Async.Parallel + let! appliedDeltas = seq { for loc, x in updates -> async { let! eff = service.Execute(loc, adjust x) in return loc,eff } } |> Async.Parallel let expectedBalances = Seq.append (seq { for l in locations -> l, 0}) appliedDeltas |> Seq.groupBy fst |> Seq.map (fun (l, xs) -> l, xs |> Seq.sumBy snd) |> Set.ofSeq (* Verify loading yields identical state *) - let! balances = seq { for loc in locations -> async { let! bal, () = service.Execute(loc,(fun _ -> (), [])) in return loc,bal } } |> Async.Parallel + let! balances = seq { for loc in locations -> async { let! bal = service.Execute(loc,(fun (Epoch.Fold.Balance bal) -> bal, [])) in return loc,bal } } |> Async.Parallel test <@ expectedBalances = Set.ofSeq balances @> } let [] ``MemoryStore properties`` maxEvents args = let store = Equinox.MemoryStore.VolatileStore() - let zeroBalance = 0 + let maxEvents = max 1 maxEvents - let shouldClose (state : Epoch.Fold.OpenState) = state.count > maxEvents - let service = Location.MemoryStore.createService (zeroBalance, shouldClose) store + let service = Location.MemoryStore.create (Epoch.zeroBalance, Epoch.toBalanceCarriedForward 5, Epoch.shouldClose maxEvents) store run service args -type Cosmos(testOutput) = +type EventStore(testOutput) = - let context, cache = Cosmos.connect () - - let log = testOutput |> TestOutputAdapter |> createLogger + let log = TestOutputLogger.create testOutput do Serilog.Log.Logger <- log - let [] properties maxEvents args = - let zeroBalance = 0 + let context, cache = EventStore.connect () + + let [] properties maxEvents args = let maxEvents = max 1 maxEvents - let shouldClose (state : Epoch.Fold.OpenState) = state.count > maxEvents - let service = Location.Cosmos.createService (zeroBalance, shouldClose) (context, cache, 50) + let service = Fc.Domain.Location.EventStore.create (Epoch.zeroBalance, Epoch.toBalanceCarriedForward 5, Epoch.shouldClose maxEvents) (context, cache, 50) run service args diff --git a/equinox-fc/Domain/Domain.fsproj b/equinox-fc/Domain/Domain.fsproj index 93a753c8b..2dbc63831 100644 --- a/equinox-fc/Domain/Domain.fsproj +++ b/equinox-fc/Domain/Domain.fsproj @@ -11,14 +11,13 @@ - - + diff --git a/equinox-fc/Domain/Infrastructure.fs b/equinox-fc/Domain/Infrastructure.fs index 2f41917e5..febe0e6b8 100644 --- a/equinox-fc/Domain/Infrastructure.fs +++ b/equinox-fc/Domain/Infrastructure.fs @@ -21,13 +21,6 @@ module InventoryId = let parse (value : string) : InventoryId = %value let toString (value : InventoryId) : string = %value -type InventoryEpochId = int -and [] inventoryEpochId -module InventoryEpochId = - let parse (value : int) : InventoryEpochId = %value - let next (value : InventoryEpochId) : InventoryEpochId = % (%value + 1) - let toString (value : InventoryEpochId) : string = string %value - type InventoryTransactionId = string and [] inventoryTransactionId module InventoryTransactionId = diff --git a/equinox-fc/Domain/Inventory.fs b/equinox-fc/Domain/Inventory.fs index fb747529d..717d96a63 100644 --- a/equinox-fc/Domain/Inventory.fs +++ b/equinox-fc/Domain/Inventory.fs @@ -1,6 +1,6 @@ -namespace Fc.Inventory +namespace Fc.Domain.Inventory -open Epoch +open Fc.Domain open Equinox.Core // we use Equinox's AsyncCacheCell helper below open FSharp.UMX @@ -10,56 +10,41 @@ type internal IdsCache<'Id>() = member __.Add ids = for x in ids do all.[x] <- () member __.Contains id = all.ContainsKey id -/// Maintains active Epoch Id in a thread-safe manner while ingesting items into the `series` of `epochs` -/// Prior to first add, reads `lookBack` epochs to seed the cache, in order to minimize the number of duplicated Ids we ingest -type Service2 internal (inventoryId, series : Series.Service, epochs : Epoch.Service, lookBack, capacity) = +/// Ingests items into a log of items, making a best effort at deduplicating as it writes +/// Prior to first add, reads recent ids, in order to minimize the number of duplicated Ids we ingest +type Service2 internal (inventoryId, epochs : Epoch.Service) = let log = Serilog.Log.ForContext() - // Maintains what we believe to be the currently open EpochId - // Guaranteed to be set only after `previousIds.AwaitValue()` - let mutable activeEpochId = Unchecked.defaultof<_> - // We want max one request in flight to establish the pre-existing Events from which the TransactionIds cache will be seeded - let previousEpochs = AsyncCacheCell> list> <| async { - let! startingId = series.ReadIngestionEpoch(inventoryId) - activeEpochId <- %startingId - let read epochId = async { let! r = epochs.TryIngest(inventoryId, epochId, (fun _ -> 1), Seq.empty) in return r.transactionIds } - return [ for epoch in (max 0 (%startingId - lookBack)) .. (%startingId - 1) -> AsyncCacheCell(read %epoch) ] } + let previousIds : AsyncCacheCell> = + let read = async { let! r = epochs.TryIngest(inventoryId, Seq.empty) in return r.transactionIds } + AsyncCacheCell read // TransactionIds cache - used to maintain a list of transactions that have already been ingested in order to avoid db round-trips let previousIds : AsyncCacheCell> = AsyncCacheCell <| async { - let! previousEpochs = previousEpochs.AwaitValue() - let! ids = seq { for x in previousEpochs -> x.AwaitValue() } |> Async.Parallel - return IdsCache.Create(Seq.concat ids) } + let! previousIds = previousIds.AwaitValue() + return IdsCache.Create(previousIds) } let tryIngest events = async { let! previousIds = previousIds.AwaitValue() - let initialEpochId = %activeEpochId - let rec aux epochId totalIngested items = async { + let rec aux totalIngested items = async { let SeqPartition f = Seq.toArray >> Array.partition f let dup, fresh = items |> SeqPartition (Epoch.Events.chooseInventoryTransactionId >> Option.exists previousIds.Contains) let fullCount = List.length items let dropping = fullCount - Array.length fresh - if dropping <> 0 then log.Information("Ignoring {count}/{fullCount} duplicate ids: {ids} for {epochId}", dropping, fullCount, dup, epochId) + if dropping <> 0 then log.Information("Ignoring {count}/{fullCount} duplicate ids: {ids}", dropping, fullCount, dup) if Array.isEmpty fresh then return totalIngested else - let! res = epochs.TryIngest(inventoryId, epochId, capacity, fresh) - log.Information("Added {count} items to {inventoryId:l}/{epochId}", res.added, inventoryId, epochId) + let! res = epochs.TryIngest(inventoryId, fresh) + log.Information("Added {count} items to {inventoryId:l}", res.added, inventoryId) // The adding is potentially redundant; we don't care previousIds.Add res.transactionIds - // Any writer noticing we've moved to a new epoch shares the burden of marking it active - if not res.isClosed && activeEpochId < %epochId then - log.Information("Marking {inventoryId:l}/{epochId} active", inventoryId, epochId) - do! series.AdvanceIngestionEpoch(inventoryId, epochId) - System.Threading.Interlocked.CompareExchange(&activeEpochId, %epochId, activeEpochId) |> ignore let totalIngestedTransactions = totalIngested + res.added - match res.rejected with - | [] -> return totalIngestedTransactions - | rej -> return! aux (InventoryEpochId.next epochId) totalIngestedTransactions rej } - return! aux initialEpochId 0 events + return totalIngestedTransactions } + return! aux 0 events } /// Upon startup, we initialize the TransactionIds cache with recent epochs; we want to kick that process off before our first ingest @@ -70,56 +55,45 @@ type Service2 internal (inventoryId, series : Series.Service, epochs : Epoch.Ser module internal Helpers = - let create inventoryId maxTransactionsPerEpoch lookBackLimit (series, epochs) = - let remainingEpochCapacity (state: Epoch.Fold.State) = - let currentLen = state.ids.Count - max 0 (maxTransactionsPerEpoch - currentLen) - Service2(inventoryId, series, epochs, lookBack = lookBackLimit, capacity = remainingEpochCapacity) - -module Cosmos = - - let create inventoryId maxTransactionsPerEpoch lookBackLimit (context, cache) = - let series = Series.Cosmos.create (context, cache) - let epochs = Epoch.Cosmos.create (context, cache) - Helpers.create inventoryId maxTransactionsPerEpoch lookBackLimit (series, epochs) + let create inventoryId epochs = + Service2(inventoryId, epochs) module EventStore = - let create inventoryId maxTransactionsPerEpoch lookBackLimit (context, cache) = - let series = Series.EventStore.create (context, cache) + let create inventoryId (context, cache) = let epochs = Epoch.EventStore.create (context, cache) - Helpers.create inventoryId maxTransactionsPerEpoch lookBackLimit (series, epochs) + Helpers.create inventoryId epochs module Processor = - type Service(transactions : Transaction.Service, locations : Fc.Location.Service, inventory : Service2) = + type Service(transactions : Transaction.Service, locations : Location.Service, inventory : Service2) = let execute transactionId = - let f = Fc.Location.Epoch.decide transactionId + let f = Location.Epoch.decide transactionId let rec aux update = async { let! action = transactions.Apply(transactionId, update) let aux event = aux (Some event) match action with | Transaction.Adjust (loc, bal) -> - match! locations.Execute(loc, f (Fc.Location.Epoch.Reset bal)) with - | Fc.Location.Epoch.Accepted _ -> return! aux Transaction.Events.Adjusted - | Fc.Location.Epoch.Denied -> return failwith "Cannot Deny Reset" - | Fc.Location.Epoch.DupFromPreviousEpoch -> return failwith "TODO walk back to previous epoch" + match! locations.Execute(loc, f (Location.Epoch.Reset bal)) with + | Location.Epoch.Accepted _ -> return! aux Transaction.Events.Adjusted + | Location.Epoch.Denied -> return failwith "Cannot Deny Reset" + | Location.Epoch.DupFromPreviousEpoch -> return failwith "TODO walk back to previous epoch" | Transaction.Remove (loc, delta) -> - match! locations.Execute(loc, f (Fc.Location.Epoch.Remove delta)) with - | Fc.Location.Epoch.Accepted bal -> return! aux (Transaction.Events.Removed { balance = bal }) - | Fc.Location.Epoch.Denied -> return! aux Transaction.Events.Failed - | Fc.Location.Epoch.DupFromPreviousEpoch -> return failwith "TODO walk back to previous epoch" + match! locations.Execute(loc, f (Location.Epoch.Remove delta)) with + | Location.Epoch.Accepted bal -> return! aux (Transaction.Events.Removed { balance = bal }) + | Location.Epoch.Denied -> return! aux Transaction.Events.Failed + | Location.Epoch.DupFromPreviousEpoch -> return failwith "TODO walk back to previous epoch" | Transaction.Add (loc, delta) -> - match! locations.Execute(loc, f (Fc.Location.Epoch.Add delta)) with - | Fc.Location.Epoch.Accepted bal -> return! aux (Transaction.Events.Added { balance = bal }) - | Fc.Location.Epoch.Denied -> return failwith "Cannot Deny Add" - | Fc.Location.Epoch.DupFromPreviousEpoch -> return failwith "TODO walk back to previous epoch" + match! locations.Execute(loc, f (Location.Epoch.Add delta)) with + | Location.Epoch.Accepted bal -> return! aux (Transaction.Events.Added { balance = bal }) + | Location.Epoch.Denied -> return failwith "Cannot Deny Add" + | Location.Epoch.DupFromPreviousEpoch -> return failwith "TODO walk back to previous epoch" | Transaction.Log (Transaction.Adjusted _) -> - let! _count = inventory.Ingest([Fc.Inventory.Epoch.Events.Adjusted { transactionId = transactionId }]) + let! _count = inventory.Ingest([Inventory.Epoch.Events.Adjusted { transactionId = transactionId }]) return! aux Transaction.Events.Logged | Transaction.Log (Transaction.Transferred _) -> - let! _count = inventory.Ingest([Fc.Inventory.Epoch.Events.Transferred { transactionId = transactionId }]) + let! _count = inventory.Ingest([Inventory.Epoch.Events.Transferred { transactionId = transactionId }]) return! aux Transaction.Events.Logged | Transaction.Finish success -> return success @@ -128,10 +102,10 @@ module Processor = let run transactionId req = execute transactionId (Some req) member __.Adjust(transactionId, location, quantity) = - run transactionId (Fc.Inventory.Transaction.Events.AdjustmentRequested { location = location; quantity = quantity }) + run transactionId (Inventory.Transaction.Events.AdjustmentRequested { location = location; quantity = quantity }) member __.TryTransfer(transactionId, source, destination, quantity) = - run transactionId (Fc.Inventory.Transaction.Events.TransferRequested { source = source; destination = destination; quantity = quantity }) + run transactionId (Inventory.Transaction.Events.TransferRequested { source = source; destination = destination; quantity = quantity }) /// Used by Watchdog to force conclusion of a transaction whose progress has stalled member __.Drive(transactionId) = async { diff --git a/equinox-fc/Domain/InventoryEpoch.fs b/equinox-fc/Domain/InventoryEpoch.fs index 6f3e27954..36dc044c3 100644 --- a/equinox-fc/Domain/InventoryEpoch.fs +++ b/equinox-fc/Domain/InventoryEpoch.fs @@ -1,30 +1,26 @@ /// Manages the ingestion (and deduplication based on a TransactionId) of events reflecting transfers or stock adjustments /// that have been effected across a given set of Inventory -/// See Inventory.Service for surface level API which manages the ingestion, including transitioning to a new Epoch when an epoch reaches 'full' state -module Fc.Inventory.Epoch +/// See Inventory.Service for surface level API which manages the ingestion +module Fc.Domain.Inventory.Epoch let [] Category = "InventoryEpoch" -let streamName (inventoryId, epochId) = FsCodec.StreamName.compose Category [InventoryId.toString inventoryId; InventoryEpochId.toString epochId] +let streamName inventoryId = FsCodec.StreamName.compose Category [InventoryId.toString inventoryId; "0"] // NB - these types and the union case names reflect the actual storage formats and hence need to be versioned with care [] module Events = type TransactionRef = { transactionId : InventoryTransactionId } - type Snapshotted = { closed: bool; ids : InventoryTransactionId[] } type Event = | Adjusted of TransactionRef | Transferred of TransactionRef - | Closed - | Snapshotted of Snapshotted interface TypeShape.UnionContract.IUnionContract let codec = FsCodec.NewtonsoftJson.Codec.Create() /// Used for deduplicating input events let chooseInventoryTransactionId = function | Adjusted { transactionId = id } | Transferred { transactionId = id } -> Some id - | Closed | Snapshotted _ -> None module Fold = @@ -32,54 +28,34 @@ module Fold = let initial = { closed = false; ids = Set.empty } let evolve state = function | (Events.Adjusted e | Events.Transferred e) -> { state with ids = Set.add e.transactionId state.ids } - | Events.Closed -> { state with closed = true } - | Events.Snapshotted e -> { closed = e.closed; ids = Set.ofArray e.ids } let fold : State -> Events.Event seq -> State = Seq.fold evolve - let isOrigin = function Events.Snapshotted _ -> true | _ -> false - let snapshot s = Events.Snapshotted { closed = s.closed; ids = Array.ofSeq s.ids } type Result = - { /// Indicates whether this epoch is closed (either previously or as a side-effect this time) - isClosed : bool - /// Count of items added to this epoch. May be less than requested due to removal of duplicates and/or rejected items + { /// Count of items added to this epoch. May be less than requested due to removal of duplicates and/or rejected items added : int - /// residual items that [are not duplicates and] were not accepted into this epoch - rejected : Events.Event list /// identifiers for all items in this epoch transactionIds : Set } -let decideSync capacity events (state : Fold.State) : Result * Events.Event list = +let decideSync events (state : Fold.State) : Result * Events.Event list = let isFresh = function | Events.Adjusted { transactionId = id } | Events.Transferred { transactionId = id } -> (not << state.ids.Contains) id - | Events.Closed | Events.Snapshotted _ -> false let news = events |> Seq.filter isFresh |> List.ofSeq - let closed, allowing, markClosed, residual = - let newCount = List.length news - if state.closed then - true, 0, false, news - else - let capacityNow = capacity state - let accepting = min capacityNow newCount - let closing = accepting = capacityNow - let residual = List.skip accepting news - closing, accepting, closing, residual - let events = - [ if allowing <> 0 then yield! news - if markClosed then yield Events.Closed ] + let newCount = List.length news + let events = [ if newCount <> 0 then yield! news ] let state' = Fold.fold state events - { isClosed = closed; added = allowing; rejected = residual; transactionIds = state'.ids }, events + { added = newCount; transactionIds = state'.ids }, events -type Service internal (resolve : InventoryId * InventoryEpochId -> Equinox.Stream) = +type Service internal (resolve : InventoryId -> Equinox.Stream) = /// Attempt ingestion of `events` into the cited Epoch. /// - None will be accepted if the Epoch is `closed` /// - The `capacity` function will be passed a non-closed `state` in order to determine number of items that can be admitted prior to closing /// - If the computed capacity result is >= the number of items being submitted (which may be 0), the Epoch will be marked Closed /// NOTE the result may include rejected items (which the caller is expected to feed into a successor epoch) - member __.TryIngest(inventoryId, epochId, capacity, events) : Async = - let stream = resolve (inventoryId, epochId) - stream.Transact(decideSync capacity events) + member __.TryIngest(inventoryId, events) : Async = + let stream = resolve inventoryId + stream.Transact(decideSync events) let create resolve = let resolve ids = @@ -87,16 +63,6 @@ let create resolve = Equinox.Stream(Serilog.Log.ForContext(), stream, maxAttempts = 2) Service(resolve) -module Cosmos = - - open Equinox.Cosmos - - let accessStrategy = AccessStrategy.Snapshot (Fold.isOrigin, Fold.snapshot) - let create (context, cache) = - let cacheStrategy = CachingStrategy.SlidingWindow (cache, System.TimeSpan.FromMinutes 20.) - let resolver = Resolver(context, Events.codec, Fold.fold, Fold.initial, cacheStrategy, accessStrategy) - create resolver.Resolve - module EventStore = open Equinox.EventStore diff --git a/equinox-fc/Domain/InventorySeries.fs b/equinox-fc/Domain/InventorySeries.fs deleted file mode 100644 index 93962680f..000000000 --- a/equinox-fc/Domain/InventorySeries.fs +++ /dev/null @@ -1,71 +0,0 @@ -/// Manages a) the ingestion epoch id b) the current checkpointed read position for a long-running Inventory Series -/// See InventoryEpoch for the logic managing the actual events logged within a given epoch -/// See Inventory.Service for the surface API which manages the writing -module Fc.Inventory.Series - -let [] Category = "InventorySeries" -let streamName inventoryId = FsCodec.StreamName.create Category (InventoryId.toString inventoryId) - -// NB - these types and the union case names reflect the actual storage formats and hence need to be versioned with care -[] -module Events = - - type Started = { epoch : InventoryEpochId } - type Event = - | Started of Started - interface TypeShape.UnionContract.IUnionContract - let codec = FsCodec.NewtonsoftJson.Codec.Create() - -module Fold = - - type State = InventoryEpochId option - let initial = None - let evolve _state = function - | Events.Started e -> Some e.epoch - let fold : State -> Events.Event seq -> State = Seq.fold evolve - -let queryActiveEpoch state = state |> Option.defaultValue (InventoryEpochId.parse 0) - -let interpretAdvanceIngestionEpoch epochId (state : Fold.State) = - if queryActiveEpoch state >= epochId then [] - else [Events.Started { epoch = epochId }] - -type Service internal (resolve : InventoryId -> Equinox.Stream) = - - member __.ReadIngestionEpoch(inventoryId) : Async = - let stream = resolve inventoryId - stream.Query queryActiveEpoch - - member __.AdvanceIngestionEpoch(inventoryId, epochId) : Async = - let stream = resolve inventoryId - stream.Transact(interpretAdvanceIngestionEpoch epochId) - -let create resolve = - // For this stream, we uniformly use stale reads as: - // a) we don't require any information from competing writers - // b) while there are competing writers [which might cause us to have to retry a Transact], this should be infrequent - let opt = Equinox.ResolveOption.AllowStale - let resolve locationId = - let stream = resolve (streamName locationId, opt) - Equinox.Stream(Serilog.Log.ForContext(), stream, maxAttempts = 2) - Service(resolve) - -module Cosmos = - - open Equinox.Cosmos - - let accessStrategy = AccessStrategy.LatestKnownEvent - let create (context, cache) = - let cacheStrategy = CachingStrategy.SlidingWindow (cache, System.TimeSpan.FromMinutes 20.) - let resolver = Resolver(context, Events.codec, Fold.fold, Fold.initial, cacheStrategy, accessStrategy) - create <| fun (id, opt) -> resolver.Resolve(id, opt) - -module EventStore = - - open Equinox.EventStore - - let accessStrategy = AccessStrategy.LatestKnownEvent - let create (context, cache) = - let cacheStrategy = CachingStrategy.SlidingWindow (cache, System.TimeSpan.FromMinutes 20.) - let resolver = Resolver(context, Events.codec, Fold.fold, Fold.initial, cacheStrategy, accessStrategy) - create <| fun (id, opt) -> resolver.Resolve(id, opt) diff --git a/equinox-fc/Domain/InventoryTransaction.fs b/equinox-fc/Domain/InventoryTransaction.fs index a775a4715..5532127a2 100644 --- a/equinox-fc/Domain/InventoryTransaction.fs +++ b/equinox-fc/Domain/InventoryTransaction.fs @@ -7,7 +7,7 @@ /// In the normal case, such an actor will bring the flow to a terminal state (Completed or Failed) /// 2) A watchdog-projector, which reacts to observed events in this Category by stepping in to complete in-flight requests that have stalled /// This represents the case where a 'happy path' actor died, or experienced another impediment on the path. -module Fc.Inventory.Transaction +module Fc.Domain.Inventory.Transaction let [] Category = "InventoryTransaction" let streamName transactionId = FsCodec.StreamName.create Category (InventoryTransactionId.toString transactionId) @@ -144,17 +144,6 @@ let create resolve = Equinox.Stream(Serilog.Log.ForContext(), stream, maxAttempts = 2) Service(resolve) -module Cosmos = - - open Equinox.Cosmos - - // in the happy path case, the event stream will typically be short, and the state cached, so snapshotting is less critical - let accessStrategy = AccessStrategy.Unoptimized - let create (context, cache) = - let cacheStrategy = CachingStrategy.SlidingWindow (cache, System.TimeSpan.FromMinutes 20.) - let resolver = Resolver(context, Events.codec, Fold.fold, Fold.initial, cacheStrategy, accessStrategy) - create <| fun (id, opt) -> resolver.Resolve(id, opt) - module EventStore = open Equinox.EventStore diff --git a/equinox-fc/Domain/Location.fs b/equinox-fc/Domain/Location.fs index ccef4427a..dddfd29fb 100644 --- a/equinox-fc/Domain/Location.fs +++ b/equinox-fc/Domain/Location.fs @@ -1,4 +1,4 @@ -namespace Fc.Location +namespace Fc.Domain.Location [] type Wip<'R> = @@ -40,13 +40,6 @@ module Helpers = let create (zeroBalance, toBalanceCarriedForward, shouldClose) (series, epochs) = Service(zeroBalance, toBalanceCarriedForward, shouldClose, series, epochs) -module Cosmos = - - let create (zeroBalance, toBalanceCarriedForward, shouldClose) (context, cache, maxAttempts) = - let series = Series.Cosmos.create (context, cache, maxAttempts) - let epochs = Epoch.Cosmos.create (context, cache, maxAttempts) - create (zeroBalance, toBalanceCarriedForward, shouldClose) (series, epochs) - module EventStore = let create (zeroBalance, toBalanceCarriedForward, shouldClose) (context, cache, maxAttempts) = diff --git a/equinox-fc/Domain/LocationEpoch.fs b/equinox-fc/Domain/LocationEpoch.fs index 5a17268e2..82146961b 100644 --- a/equinox-fc/Domain/LocationEpoch.fs +++ b/equinox-fc/Domain/LocationEpoch.fs @@ -1,7 +1,7 @@ /// Manages Stock adjustments and deltas for a given Location /// Provides for controlled opening and closing of an epoch, carrying forward incoming balances when a given Epoch reaches a 'full' state /// See Location.Service for the logic that allows competing readers/writers to co-operate in bringing this about -module Fc.Location.Epoch +module Fc.Domain.Location.Epoch let [] Category = "LocationEpoch" let streamName (locationId, epochId) = FsCodec.StreamName.compose Category [LocationId.toString locationId; LocationEpochId.toString epochId] @@ -35,6 +35,8 @@ module Fold = let (|Current|) = function | (Init { initial = bal } | Step { balance = bal }) :: _ -> bal | [] -> failwith "Cannot transact when no CarriedForward" + let (|History|) = function Initial -> [] | (Open history | Closed history) -> history + let (|Balance|) (History h) = (|Current|) h let evolve state event = match event, state with | Events.CarriedForward e, Initial -> Open [Init e] @@ -47,6 +49,20 @@ module Fold = failwithf "Unexpected %A when %A" e s let fold = Seq.fold evolve +let zeroBalance : Events.CarriedForward = + { initial = 0 + recentTransactions = [||] } +let shouldClose maxEvents deltas = + List.length deltas > maxEvents +let toBalanceCarriedForward idsRetentionWindow (history : Fold.Record list) : Events.CarriedForward = + let steps = + history + |> Seq.choose (function Fold.Record.Step s -> Some s | Fold.Record.Init _ -> None) + |> Seq.rev + |> Seq.cache + { initial = let final = Seq.head steps in final.balance + recentTransactions = seq { for x in steps -> x.id } |> Seq.truncate idsRetentionWindow |> Array.ofSeq } + /// Holds events accumulated from a series of decisions while also evolving the presented `state` to reflect the pended events type private Accumulator() = let acc = ResizeArray() @@ -76,7 +92,7 @@ let sync (carriedForward : Events.CarriedForward option) acc.Ingest state <| match state with | Fold.Initial -> failwith "We've just guaranteed not Initial" - | Fold.Open history -> let r, es = decide state in Some r, es + | Fold.Open _ -> let r, es = decide state in Some r, es | Fold.Closed _ -> None, [] // 3. Finally (iff we're `Open`, have run a `decide` and `shouldClose`), we generate a Closed event let (history, isOpen), _ = @@ -139,16 +155,6 @@ let create resolve maxAttempts = Equinox.Stream(Serilog.Log.ForContext(), stream, maxAttempts = maxAttempts) Service (resolve) -module Cosmos = - - open Equinox.Cosmos - - let accessStrategy = AccessStrategy.Unoptimized - let create (context, cache, maxAttempts) = - let cacheStrategy = CachingStrategy.SlidingWindow (cache, System.TimeSpan.FromMinutes 20.) - let resolver = Resolver(context, Events.codec, Fold.fold, Fold.initial, cacheStrategy, accessStrategy) - create resolver.Resolve maxAttempts - module EventStore = open Equinox.EventStore diff --git a/equinox-fc/Domain/LocationSeries.fs b/equinox-fc/Domain/LocationSeries.fs index 53df26280..604b898fa 100644 --- a/equinox-fc/Domain/LocationSeries.fs +++ b/equinox-fc/Domain/LocationSeries.fs @@ -1,5 +1,5 @@ /// Manages the active epoch for a given Location -module Fc.Location.Series +module Fc.Domain.Location.Series let [] Category = "LocationSeries" let streamName locationId = FsCodec.StreamName.create Category (LocationId.toString locationId) @@ -44,17 +44,6 @@ let create resolve maxAttempts = Equinox.Stream(Serilog.Log.ForContext(), stream, maxAttempts = maxAttempts) Service(resolve) -module Cosmos = - - open Equinox.Cosmos - - let accessStrategy = AccessStrategy.LatestKnownEvent - let create (context, cache, maxAttempts) = - let cacheStrategy = CachingStrategy.SlidingWindow (cache, System.TimeSpan.FromMinutes 20.) - let resolver = Resolver(context, Events.codec, Fold.fold, Fold.initial, cacheStrategy, accessStrategy) - let resolve (id, opt) = resolver.Resolve(id, opt) - create resolve maxAttempts - module EventStore = open Equinox.EventStore diff --git a/equinox-fc/Web/Program.fs b/equinox-fc/Web/Program.fs index bd3be1375..396d5c322 100644 --- a/equinox-fc/Web/Program.fs +++ b/equinox-fc/Web/Program.fs @@ -37,28 +37,24 @@ module Args = | Some x -> x let isEnvVarTrue varName = EnvVar.tryGet varName |> Option.exists (fun s -> String.Equals(s, bool.TrueString, StringComparison.OrdinalIgnoreCase)) open Argu - open Equinox.Cosmos open Equinox.EventStore [] type Parameters = | [] Verbose | [] Es of ParseResults - | [] Cosmos of ParseResults interface IArgParserTemplate with member a.Usage = match a with | Verbose -> "request Verbose Logging. Default: off." | Es _ -> "specify EventStore input parameters." - | Cosmos _ -> "specify CosmosDB input parameters." and Arguments(a : ParseResults) = member __.Verbose = a.Contains Parameters.Verbose member __.StatsInterval = TimeSpan.FromMinutes 1. - member val Source : Choice = + member val Source : EsArguments = match a.TryGetSubCommand() with - | Some (Es es) -> Choice1Of2 (EsArguments es) - | Some (Cosmos cosmos) -> Choice2Of2 (CosmosArguments cosmos) + | Some (Es es) -> (EsArguments es) | _ -> raise (MissingArg "Must specify one of cosmos or es for Src") and [] EsParameters = | [] Verbose @@ -86,7 +82,7 @@ module Args = match __.Tcp, __.Port with | false, None -> Discovery.GossipDns __.Host | false, Some p -> Discovery.GossipDnsCustomPort (__.Host, p) - | true, None -> Discovery.Uri (UriBuilder("tcp", __.Host).Uri) + | true, None -> Discovery.Uri (UriBuilder("tcp", __.Host, 1113).Uri) | true, Some p -> Discovery.Uri (UriBuilder("tcp", __.Host, p).Uri) member __.Tcp = a.Contains Tcp || isEnvVarTrue "EQUINOX_ES_TCP" member __.Port = match a.TryGetResult Port with Some x -> Some x | None -> EnvVar.tryGet "EQUINOX_ES_PORT" |> Option.map int @@ -106,40 +102,6 @@ module Args = let tags=["M", Environment.MachineName; "I", Guid.NewGuid() |> string] Connector(x.User, x.Password, x.Timeout, x.Retries, log=log, heartbeatTimeout=x.Heartbeat, tags=tags) .Establish(appName, discovery, connectionStrategy) |> Async.RunSynchronously - and [] CosmosParameters = - | [] Connection of string - | [] ConnectionMode of ConnectionMode - | [] Database of string - | [] Container of string - | [] Timeout of float - | [] Retries of int - | [] RetriesWaitTime of float - interface IArgParserTemplate with - member a.Usage = - match a with - | ConnectionMode _ -> "override the connection mode. Default: Direct." - | Connection _ -> "specify a connection string for a Cosmos account. (optional if environment variable EQUINOX_COSMOS_CONNECTION specified)" - | Database _ -> "specify a database name for Cosmos store. (optional if environment variable EQUINOX_COSMOS_DATABASE specified)" - | Container _ -> "specify a container name for Cosmos store. (optional if environment variable EQUINOX_COSMOS_CONTAINER specified)" - | Timeout _ -> "specify operation timeout in seconds. Default: 5." - | Retries _ -> "specify operation retries. Default: 1." - | RetriesWaitTime _ -> "specify max wait-time for retry when being throttled by Cosmos in seconds. Default: 5." - and CosmosArguments(a : ParseResults) = - member __.Mode = a.GetResult(CosmosParameters.ConnectionMode, Equinox.Cosmos.ConnectionMode.Direct) - member __.Connection = a.TryGetResult CosmosParameters.Connection |> defaultWithEnvVar "EQUINOX_COSMOS_CONNECTION" "Connection" - member __.Database = a.TryGetResult CosmosParameters.Database |> defaultWithEnvVar "EQUINOX_COSMOS_DATABASE" "Database" - member __.Container = a.TryGetResult CosmosParameters.Container |> defaultWithEnvVar "EQUINOX_COSMOS_CONTAINER" "Container" - member __.Timeout = a.GetResult(CosmosParameters.Timeout, 5.) |> TimeSpan.FromSeconds - member __.Retries = a.GetResult(CosmosParameters.Retries, 1) - member __.MaxRetryWaitTime = a.GetResult(CosmosParameters.RetriesWaitTime, 5.) |> TimeSpan.FromSeconds - member x.BuildConnectionDetails() = - let (Discovery.UriAndKey (endpointUri, _) as discovery) = Discovery.FromConnectionString x.Connection - Log.Information("CosmosDb {mode} {endpointUri} Database {database} Container {container}", - x.Mode, endpointUri, x.Database, x.Container) - Log.Information("CosmosDb timeout {timeout}s; Throttling retries {retries}, max wait {maxRetryWaitTime}s", - (let t = x.Timeout in t.TotalSeconds), x.Retries, (let t = x.MaxRetryWaitTime in t.TotalSeconds)) - let connector = Equinox.Cosmos.Connector(x.Timeout, x.Retries, x.MaxRetryWaitTime, Log.Logger, mode=x.Mode) - discovery, x.Database, x.Container, connector /// Parse the commandline; can throw exceptions in response to missing arguments and/or `-h`/`--help` args let parse argv = @@ -184,6 +146,8 @@ type Startup() = .UseEndpoints(fun endpoints -> endpoints.MapControllers() |> ignore) |> ignore +open Fc.Domain + let build (args : Args.Arguments) = let cache = Equinox.Cache(AppName, sizeMb = 10) @@ -191,35 +155,25 @@ let build (args : Args.Arguments) = let maxTransactionsPerEpoch = 100 let lookBackLimit = 10 - let zeroBalance : Fc.Location.Epoch.Events.CarriedForward = { initial = 0; recentTransactions = [||] } + let zeroBalance : Location.Epoch.Events.CarriedForward = { initial = 0; recentTransactions = [||] } let chooseTransactionIds = function - | Fc.Location.Epoch.Fold.Init { recentTransactions = ids } -> Seq.ofArray ids - | Fc.Location.Epoch.Fold.Step { id = id } -> Seq.singleton id - let toBalanceCarriedForward (Fc.Location.Epoch.Fold.Current cur as records) : Fc.Location.Epoch.Events.CarriedForward = + | Location.Epoch.Fold.Init { recentTransactions = ids } -> Seq.ofArray ids + | Location.Epoch.Fold.Step { id = id } -> Seq.singleton id + let toBalanceCarriedForward (Location.Epoch.Fold.Current cur as records) : Location.Epoch.Events.CarriedForward = { initial = cur; recentTransactions = records |> Seq.collect chooseTransactionIds |> Seq.truncate 5 |> Seq.toArray } let shouldClose x = false let maxAttempts = 3 let transactions, locations, inventory = - match args.Source with - | Choice1Of2 es -> - let connection = es.Connect(Log.Logger, Log.Logger, AppName, Equinox.EventStore.ConnectionStrategy.ClusterSingle Equinox.EventStore.NodePreference.Master) - let context = Equinox.EventStore.Context(connection, Equinox.EventStore.BatchingPolicy(maxBatchSize = 500)) - - let transactions = Fc.Inventory.Transaction.EventStore.create (context, cache) - let locations = Fc.Location.EventStore.create (zeroBalance, toBalanceCarriedForward, shouldClose) (context, cache, maxAttempts) - let inventory = Fc.Inventory.EventStore.create inventoryId maxTransactionsPerEpoch lookBackLimit (context, cache) - transactions, locations, inventory - | Choice2Of2 cosmos -> - let (discovery, database, container, connector) = cosmos.BuildConnectionDetails() - let connection = connector.Connect(AppName, discovery) |> Async.RunSynchronously - let context = Equinox.Cosmos.Context(connection, database, container) - - let transactions = Fc.Inventory.Transaction.Cosmos.create (context, cache) - let locations = Fc.Location.Cosmos.create (zeroBalance, toBalanceCarriedForward, shouldClose) (context, cache, maxAttempts) - let inventory = Fc.Inventory.Cosmos.create inventoryId maxTransactionsPerEpoch lookBackLimit (context, cache) - transactions, locations, inventory - let processor = Fc.Inventory.Processor.Service(transactions, locations, inventory) + let es = args.Source + let connection = es.Connect(Log.Logger, Log.Logger, AppName, Equinox.EventStore.ConnectionStrategy.ClusterSingle Equinox.EventStore.NodePreference.Master) + let context = Equinox.EventStore.Context(connection, Equinox.EventStore.BatchingPolicy(maxBatchSize = 500)) + + let transactions = Inventory.Transaction.EventStore.create (context, cache) + let locations = Location.EventStore.create (zeroBalance, toBalanceCarriedForward, shouldClose) (context, cache, maxAttempts) + let inventory = Inventory.EventStore.create inventoryId (context, cache) + transactions, locations, inventory + let processor = Inventory.Processor.Service(transactions, locations, inventory) processor let run argv args = diff --git a/equinox-fc/Web/Web.fsproj b/equinox-fc/Web/Web.fsproj index cae31189a..4b651e965 100644 --- a/equinox-fc/Web/Web.fsproj +++ b/equinox-fc/Web/Web.fsproj @@ -13,7 +13,6 @@ - From 1985df271d5b61cc9bbb1e1036601b95cab0e8d4 Mon Sep 17 00:00:00 2001 From: Ruben Bartelink Date: Thu, 11 Jun 2020 13:55:22 +0100 Subject: [PATCH 45/51] Fix changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ad7af7d4..93582449e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ The `Unreleased` section name is replaced by the expected version of next releas ### Added - `eqxShipping`: Unit and integration tests [#70](https://github.com/jet/dotnet-templates/pull/70) +- `eqxFc`: Fulfilment-Center inspired example utilizing Process Manager patterns with basic `Equinox.MemoryStore` and `Equinox.EventStore` tests [#40](https://github.com/jet/dotnet-templates/pulls/40) ### Changed ### Removed @@ -191,7 +192,6 @@ The `Unreleased` section name is replaced by the expected version of next releas ### Added - Add handling for `-T` TCP/IP switch on EventStore args [#46](https://github.com/jet/dotnet-templates/pulls/46) -- `equinox-fc`: Fulfilment-Center inspired example utilizing Process Manager patterns with basic `Equinox.MemoryStore` and `Equinox.Cosmos` tests [#40](https://github.com/jet/dotnet-templates/pulls/40) ### Changed From c2adeb0009b62466ce546b7cac2aa0ccc525ded4 Mon Sep 17 00:00:00 2001 From: Ruben Bartelink Date: Thu, 11 Jun 2020 18:01:38 +0100 Subject: [PATCH 46/51] Cleanup more --- equinox-fc/Domain.Tests/Domain.Tests.fsproj | 9 +++++---- equinox-fc/Domain/Domain.fsproj | 4 ++-- equinox-fc/Web/Web.fsproj | 2 +- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/equinox-fc/Domain.Tests/Domain.Tests.fsproj b/equinox-fc/Domain.Tests/Domain.Tests.fsproj index 636c4384e..edf669ae5 100644 --- a/equinox-fc/Domain.Tests/Domain.Tests.fsproj +++ b/equinox-fc/Domain.Tests/Domain.Tests.fsproj @@ -15,11 +15,12 @@ + + - - - - + + + diff --git a/equinox-fc/Domain/Domain.fsproj b/equinox-fc/Domain/Domain.fsproj index 2dbc63831..ad65d1579 100644 --- a/equinox-fc/Domain/Domain.fsproj +++ b/equinox-fc/Domain/Domain.fsproj @@ -16,8 +16,8 @@ - - + + diff --git a/equinox-fc/Web/Web.fsproj b/equinox-fc/Web/Web.fsproj index 4b651e965..5ef2d9568 100644 --- a/equinox-fc/Web/Web.fsproj +++ b/equinox-fc/Web/Web.fsproj @@ -13,7 +13,7 @@ - + From ff265c588a48745109b5531dcc6412286f0c368f Mon Sep 17 00:00:00 2001 From: Ruben Bartelink Date: Thu, 11 Jun 2020 18:33:23 +0100 Subject: [PATCH 47/51] Clean PM naming --- equinox-fc/Domain/Inventory.fs | 97 ++++++++++++++++------------------ equinox-fc/Web/Program.fs | 7 ++- 2 files changed, 50 insertions(+), 54 deletions(-) diff --git a/equinox-fc/Domain/Inventory.fs b/equinox-fc/Domain/Inventory.fs index 717d96a63..7345b443c 100644 --- a/equinox-fc/Domain/Inventory.fs +++ b/equinox-fc/Domain/Inventory.fs @@ -2,7 +2,6 @@ namespace Fc.Domain.Inventory open Fc.Domain open Equinox.Core // we use Equinox's AsyncCacheCell helper below -open FSharp.UMX type internal IdsCache<'Id>() = let all = System.Collections.Concurrent.ConcurrentDictionary<'Id, unit>() // Bounded only by relatively low number of physical pick tickets IRL @@ -12,9 +11,9 @@ type internal IdsCache<'Id>() = /// Ingests items into a log of items, making a best effort at deduplicating as it writes /// Prior to first add, reads recent ids, in order to minimize the number of duplicated Ids we ingest -type Service2 internal (inventoryId, epochs : Epoch.Service) = +type Service internal (inventoryId, epochs : Epoch.Service) = - let log = Serilog.Log.ForContext() + static let log = Serilog.Log.ForContext() // We want max one request in flight to establish the pre-existing Events from which the TransactionIds cache will be seeded let previousIds : AsyncCacheCell> = @@ -56,7 +55,7 @@ type Service2 internal (inventoryId, epochs : Epoch.Service) = module internal Helpers = let create inventoryId epochs = - Service2(inventoryId, epochs) + Service(inventoryId, epochs) module EventStore = @@ -64,49 +63,47 @@ module EventStore = let epochs = Epoch.EventStore.create (context, cache) Helpers.create inventoryId epochs -module Processor = - - type Service(transactions : Transaction.Service, locations : Location.Service, inventory : Service2) = - - let execute transactionId = - let f = Location.Epoch.decide transactionId - let rec aux update = async { - let! action = transactions.Apply(transactionId, update) - let aux event = aux (Some event) - match action with - | Transaction.Adjust (loc, bal) -> - match! locations.Execute(loc, f (Location.Epoch.Reset bal)) with - | Location.Epoch.Accepted _ -> return! aux Transaction.Events.Adjusted - | Location.Epoch.Denied -> return failwith "Cannot Deny Reset" - | Location.Epoch.DupFromPreviousEpoch -> return failwith "TODO walk back to previous epoch" - | Transaction.Remove (loc, delta) -> - match! locations.Execute(loc, f (Location.Epoch.Remove delta)) with - | Location.Epoch.Accepted bal -> return! aux (Transaction.Events.Removed { balance = bal }) - | Location.Epoch.Denied -> return! aux Transaction.Events.Failed - | Location.Epoch.DupFromPreviousEpoch -> return failwith "TODO walk back to previous epoch" - | Transaction.Add (loc, delta) -> - match! locations.Execute(loc, f (Location.Epoch.Add delta)) with - | Location.Epoch.Accepted bal -> return! aux (Transaction.Events.Added { balance = bal }) - | Location.Epoch.Denied -> return failwith "Cannot Deny Add" - | Location.Epoch.DupFromPreviousEpoch -> return failwith "TODO walk back to previous epoch" - | Transaction.Log (Transaction.Adjusted _) -> - let! _count = inventory.Ingest([Inventory.Epoch.Events.Adjusted { transactionId = transactionId }]) - return! aux Transaction.Events.Logged - | Transaction.Log (Transaction.Transferred _) -> - let! _count = inventory.Ingest([Inventory.Epoch.Events.Transferred { transactionId = transactionId }]) - return! aux Transaction.Events.Logged - | Transaction.Finish success -> - return success - } - aux - let run transactionId req = execute transactionId (Some req) - - member __.Adjust(transactionId, location, quantity) = - run transactionId (Inventory.Transaction.Events.AdjustmentRequested { location = location; quantity = quantity }) - - member __.TryTransfer(transactionId, source, destination, quantity) = - run transactionId (Inventory.Transaction.Events.TransferRequested { source = source; destination = destination; quantity = quantity }) - - /// Used by Watchdog to force conclusion of a transaction whose progress has stalled - member __.Drive(transactionId) = async { - let! _ = execute transactionId None in () } \ No newline at end of file +type ProcessManager(transactions : Transaction.Service, locations : Location.Service, inventory : Service) = + + let execute transactionId = + let f = Location.Epoch.decide transactionId + let rec aux update = async { + let! action = transactions.Apply(transactionId, update) + let aux event = aux (Some event) + match action with + | Transaction.Adjust (loc, bal) -> + match! locations.Execute(loc, f (Location.Epoch.Reset bal)) with + | Location.Epoch.Accepted _ -> return! aux Transaction.Events.Adjusted + | Location.Epoch.Denied -> return failwith "Cannot Deny Reset" + | Location.Epoch.DupFromPreviousEpoch -> return failwith "TODO walk back to previous epoch" + | Transaction.Remove (loc, delta) -> + match! locations.Execute(loc, f (Location.Epoch.Remove delta)) with + | Location.Epoch.Accepted bal -> return! aux (Transaction.Events.Removed { balance = bal }) + | Location.Epoch.Denied -> return! aux Transaction.Events.Failed + | Location.Epoch.DupFromPreviousEpoch -> return failwith "TODO walk back to previous epoch" + | Transaction.Add (loc, delta) -> + match! locations.Execute(loc, f (Location.Epoch.Add delta)) with + | Location.Epoch.Accepted bal -> return! aux (Transaction.Events.Added { balance = bal }) + | Location.Epoch.Denied -> return failwith "Cannot Deny Add" + | Location.Epoch.DupFromPreviousEpoch -> return failwith "TODO walk back to previous epoch" + | Transaction.Log (Transaction.Adjusted _) -> + let! _count = inventory.Ingest([Inventory.Epoch.Events.Adjusted { transactionId = transactionId }]) + return! aux Transaction.Events.Logged + | Transaction.Log (Transaction.Transferred _) -> + let! _count = inventory.Ingest([Inventory.Epoch.Events.Transferred { transactionId = transactionId }]) + return! aux Transaction.Events.Logged + | Transaction.Finish success -> + return success + } + aux + let run transactionId req = execute transactionId (Some req) + + member __.Adjust(transactionId, location, quantity) = + run transactionId (Inventory.Transaction.Events.AdjustmentRequested { location = location; quantity = quantity }) + + member __.TryTransfer(transactionId, source, destination, quantity) = + run transactionId (Inventory.Transaction.Events.TransferRequested { source = source; destination = destination; quantity = quantity }) + + /// Used by Watchdog to force conclusion of a transaction whose progress has stalled + member __.Drive(transactionId) = async { + let! _ = execute transactionId None in () } \ No newline at end of file diff --git a/equinox-fc/Web/Program.fs b/equinox-fc/Web/Program.fs index 396d5c322..252850565 100644 --- a/equinox-fc/Web/Program.fs +++ b/equinox-fc/Web/Program.fs @@ -173,15 +173,14 @@ let build (args : Args.Arguments) = let locations = Location.EventStore.create (zeroBalance, toBalanceCarriedForward, shouldClose) (context, cache, maxAttempts) let inventory = Inventory.EventStore.create inventoryId (context, cache) transactions, locations, inventory - let processor = Inventory.Processor.Service(transactions, locations, inventory) - processor + Inventory.ProcessManager(transactions, locations, inventory) let run argv args = - let processor = build args + let processManager = build args WebHost .CreateDefaultBuilder(argv) .UseSerilog() - .ConfigureServices(fun svc -> svc.AddSingleton(processor) |> ignore) + .ConfigureServices(fun svc -> svc.AddSingleton(processManager) |> ignore) .UseStartup() .Build() .Run() From 9d49d38a2d92e1dbc73bb74cba66b516171a72a0 Mon Sep 17 00:00:00 2001 From: Ruben Bartelink Date: Fri, 12 Jun 2020 10:28:15 +0100 Subject: [PATCH 48/51] Extract ProcessManager as Stock* --- dotnet-templates.sln | 1 + equinox-fc/Domain/Domain.fsproj | 3 +- equinox-fc/Domain/Inventory.fs | 46 --------------- equinox-fc/Domain/InventoryEpoch.fs | 2 +- equinox-fc/Domain/LocationEpoch.fs | 2 +- equinox-fc/Domain/LocationSeries.fs | 2 +- equinox-fc/Domain/StockProcessManager.fs | 57 +++++++++++++++++++ ...toryTransaction.fs => StockTransaction.fs} | 4 +- equinox-fc/Web/Program.fs | 32 +++-------- 9 files changed, 72 insertions(+), 77 deletions(-) create mode 100644 equinox-fc/Domain/StockProcessManager.fs rename equinox-fc/Domain/{InventoryTransaction.fs => StockTransaction.fs} (99%) diff --git a/dotnet-templates.sln b/dotnet-templates.sln index a38acd3b8..ab160a065 100644 --- a/dotnet-templates.sln +++ b/dotnet-templates.sln @@ -101,6 +101,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "eqxFc", "eqxFc", "{4946576F ProjectSection(SolutionItems) = preProject equinox-fc\.template.config\template.json = equinox-fc\.template.config\template.json equinox-fc\README.md = equinox-fc\README.md + equinox-fc\Fc.sln = equinox-fc\Fc.sln EndProjectSection EndProject Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Domain", "equinox-fc\Domain\Domain.fsproj", "{B3CFC965-6AB9-47E8-AA47-548A8D8A2E2C}" diff --git a/equinox-fc/Domain/Domain.fsproj b/equinox-fc/Domain/Domain.fsproj index ad65d1579..3d0c2a748 100644 --- a/equinox-fc/Domain/Domain.fsproj +++ b/equinox-fc/Domain/Domain.fsproj @@ -12,8 +12,9 @@ - + + diff --git a/equinox-fc/Domain/Inventory.fs b/equinox-fc/Domain/Inventory.fs index 7345b443c..6d6578df4 100644 --- a/equinox-fc/Domain/Inventory.fs +++ b/equinox-fc/Domain/Inventory.fs @@ -1,6 +1,5 @@ namespace Fc.Domain.Inventory -open Fc.Domain open Equinox.Core // we use Equinox's AsyncCacheCell helper below type internal IdsCache<'Id>() = @@ -62,48 +61,3 @@ module EventStore = let create inventoryId (context, cache) = let epochs = Epoch.EventStore.create (context, cache) Helpers.create inventoryId epochs - -type ProcessManager(transactions : Transaction.Service, locations : Location.Service, inventory : Service) = - - let execute transactionId = - let f = Location.Epoch.decide transactionId - let rec aux update = async { - let! action = transactions.Apply(transactionId, update) - let aux event = aux (Some event) - match action with - | Transaction.Adjust (loc, bal) -> - match! locations.Execute(loc, f (Location.Epoch.Reset bal)) with - | Location.Epoch.Accepted _ -> return! aux Transaction.Events.Adjusted - | Location.Epoch.Denied -> return failwith "Cannot Deny Reset" - | Location.Epoch.DupFromPreviousEpoch -> return failwith "TODO walk back to previous epoch" - | Transaction.Remove (loc, delta) -> - match! locations.Execute(loc, f (Location.Epoch.Remove delta)) with - | Location.Epoch.Accepted bal -> return! aux (Transaction.Events.Removed { balance = bal }) - | Location.Epoch.Denied -> return! aux Transaction.Events.Failed - | Location.Epoch.DupFromPreviousEpoch -> return failwith "TODO walk back to previous epoch" - | Transaction.Add (loc, delta) -> - match! locations.Execute(loc, f (Location.Epoch.Add delta)) with - | Location.Epoch.Accepted bal -> return! aux (Transaction.Events.Added { balance = bal }) - | Location.Epoch.Denied -> return failwith "Cannot Deny Add" - | Location.Epoch.DupFromPreviousEpoch -> return failwith "TODO walk back to previous epoch" - | Transaction.Log (Transaction.Adjusted _) -> - let! _count = inventory.Ingest([Inventory.Epoch.Events.Adjusted { transactionId = transactionId }]) - return! aux Transaction.Events.Logged - | Transaction.Log (Transaction.Transferred _) -> - let! _count = inventory.Ingest([Inventory.Epoch.Events.Transferred { transactionId = transactionId }]) - return! aux Transaction.Events.Logged - | Transaction.Finish success -> - return success - } - aux - let run transactionId req = execute transactionId (Some req) - - member __.Adjust(transactionId, location, quantity) = - run transactionId (Inventory.Transaction.Events.AdjustmentRequested { location = location; quantity = quantity }) - - member __.TryTransfer(transactionId, source, destination, quantity) = - run transactionId (Inventory.Transaction.Events.TransferRequested { source = source; destination = destination; quantity = quantity }) - - /// Used by Watchdog to force conclusion of a transaction whose progress has stalled - member __.Drive(transactionId) = async { - let! _ = execute transactionId None in () } \ No newline at end of file diff --git a/equinox-fc/Domain/InventoryEpoch.fs b/equinox-fc/Domain/InventoryEpoch.fs index 36dc044c3..21bd6562a 100644 --- a/equinox-fc/Domain/InventoryEpoch.fs +++ b/equinox-fc/Domain/InventoryEpoch.fs @@ -60,7 +60,7 @@ type Service internal (resolve : InventoryId -> Equinox.Stream(), stream, maxAttempts = 2) + Equinox.Stream(Serilog.Log.ForContext(), stream, maxAttempts=2) Service(resolve) module EventStore = diff --git a/equinox-fc/Domain/LocationEpoch.fs b/equinox-fc/Domain/LocationEpoch.fs index 82146961b..1b0422e05 100644 --- a/equinox-fc/Domain/LocationEpoch.fs +++ b/equinox-fc/Domain/LocationEpoch.fs @@ -152,7 +152,7 @@ type Service internal (resolve : LocationId * LocationEpochId -> Equinox.Stream< let create resolve maxAttempts = let resolve locationId = let stream = resolve (streamName locationId) - Equinox.Stream(Serilog.Log.ForContext(), stream, maxAttempts = maxAttempts) + Equinox.Stream(Serilog.Log.ForContext(), stream, maxAttempts=maxAttempts) Service (resolve) module EventStore = diff --git a/equinox-fc/Domain/LocationSeries.fs b/equinox-fc/Domain/LocationSeries.fs index 604b898fa..d58274aa8 100644 --- a/equinox-fc/Domain/LocationSeries.fs +++ b/equinox-fc/Domain/LocationSeries.fs @@ -41,7 +41,7 @@ let create resolve maxAttempts = let opt = Equinox.ResolveOption.AllowStale let resolve locationId = let stream = resolve (streamName locationId, opt) - Equinox.Stream(Serilog.Log.ForContext(), stream, maxAttempts = maxAttempts) + Equinox.Stream(Serilog.Log.ForContext(), stream, maxAttempts=maxAttempts) Service(resolve) module EventStore = diff --git a/equinox-fc/Domain/StockProcessManager.fs b/equinox-fc/Domain/StockProcessManager.fs new file mode 100644 index 000000000..8dea6e516 --- /dev/null +++ b/equinox-fc/Domain/StockProcessManager.fs @@ -0,0 +1,57 @@ +module Fc.Domain.StockProcessManager + +type Service(transactions : StockTransaction.Service, locations : Location.Service, inventory : Inventory.Service) = + + let execute transactionId = + let f = Location.Epoch.decide transactionId + let rec aux update = async { + let! action = transactions.Apply(transactionId, update) + let aux event = aux (Some event) + match action with + | StockTransaction.Adjust (loc, bal) -> + match! locations.Execute(loc, f (Location.Epoch.Reset bal)) with + | Location.Epoch.Accepted _ -> return! aux StockTransaction.Events.Adjusted + | Location.Epoch.Denied -> return failwith "Cannot Deny Reset" + | Location.Epoch.DupFromPreviousEpoch -> return failwith "TODO walk back to previous epoch" + | StockTransaction.Remove (loc, delta) -> + match! locations.Execute(loc, f (Location.Epoch.Remove delta)) with + | Location.Epoch.Accepted bal -> return! aux (StockTransaction.Events.Removed { balance = bal }) + | Location.Epoch.Denied -> return! aux StockTransaction.Events.Failed + | Location.Epoch.DupFromPreviousEpoch -> return failwith "TODO walk back to previous epoch" + | StockTransaction.Add (loc, delta) -> + match! locations.Execute(loc, f (Location.Epoch.Add delta)) with + | Location.Epoch.Accepted bal -> return! aux (StockTransaction.Events.Added { balance = bal }) + | Location.Epoch.Denied -> return failwith "Cannot Deny Add" + | Location.Epoch.DupFromPreviousEpoch -> return failwith "TODO walk back to previous epoch" + | StockTransaction.Log (StockTransaction.Adjusted _) -> + let! _count = inventory.Ingest([Inventory.Epoch.Events.Adjusted { transactionId = transactionId }]) + return! aux StockTransaction.Events.Logged + | StockTransaction.Log (StockTransaction.Transferred _) -> + let! _count = inventory.Ingest([Inventory.Epoch.Events.Transferred { transactionId = transactionId }]) + return! aux StockTransaction.Events.Logged + | StockTransaction.Finish success -> + return success + } + aux + let run transactionId req = execute transactionId (Some req) + + member __.Adjust(transactionId, location, quantity) = + run transactionId (StockTransaction.Events.AdjustmentRequested { location = location; quantity = quantity }) + + member __.TryTransfer(transactionId, source, destination, quantity) = + run transactionId (StockTransaction.Events.TransferRequested { source = source; destination = destination; quantity = quantity }) + + /// Used by Watchdog to force conclusion of a transaction whose progress has stalled + member __.Drive(transactionId) = async { + let! _ = execute transactionId None in () } + +module EventStore = + + let create inventoryId (epochLen, idsWindow, maxAttempts) (context, cache) = + let transactions, locations, inventory = + let transactions = StockTransaction.EventStore.create (context, cache) + let zero, cf, sc = (Location.Epoch.zeroBalance, Location.Epoch.toBalanceCarriedForward idsWindow, Location.Epoch.shouldClose epochLen) + let locations = Location.EventStore.create (zero, cf, sc) (context, cache, maxAttempts) + let inventory = Inventory.EventStore.create inventoryId (context, cache) + transactions, locations, inventory + Service(transactions, locations, inventory) diff --git a/equinox-fc/Domain/InventoryTransaction.fs b/equinox-fc/Domain/StockTransaction.fs similarity index 99% rename from equinox-fc/Domain/InventoryTransaction.fs rename to equinox-fc/Domain/StockTransaction.fs index 5532127a2..f21d6513f 100644 --- a/equinox-fc/Domain/InventoryTransaction.fs +++ b/equinox-fc/Domain/StockTransaction.fs @@ -7,7 +7,7 @@ /// In the normal case, such an actor will bring the flow to a terminal state (Completed or Failed) /// 2) A watchdog-projector, which reacts to observed events in this Category by stepping in to complete in-flight requests that have stalled /// This represents the case where a 'happy path' actor died, or experienced another impediment on the path. -module Fc.Domain.Inventory.Transaction +module Fc.Domain.StockTransaction let [] Category = "InventoryTransaction" let streamName transactionId = FsCodec.StreamName.create Category (InventoryTransactionId.toString transactionId) @@ -141,7 +141,7 @@ let create resolve = let opt = Equinox.AllowStale let resolve inventoryTransactionId = let stream = resolve (streamName inventoryTransactionId, opt) - Equinox.Stream(Serilog.Log.ForContext(), stream, maxAttempts = 2) + Equinox.Stream(Serilog.Log.ForContext(), stream, maxAttempts=2) Service(resolve) module EventStore = diff --git a/equinox-fc/Web/Program.fs b/equinox-fc/Web/Program.fs index 252850565..d0da80df9 100644 --- a/equinox-fc/Web/Program.fs +++ b/equinox-fc/Web/Program.fs @@ -122,7 +122,7 @@ module Logging = c.WriteTo.Console(theme=Sinks.SystemConsole.Themes.AnsiConsoleTheme.Code, outputTemplate=t) |> fun c -> c.CreateLogger() -let [] AppName = "Host" +let [] AppName = "Fc.Web" /// Defines the Hosting configuration, including registration of the store and backend services type Startup() = @@ -146,34 +146,16 @@ type Startup() = .UseEndpoints(fun endpoints -> endpoints.MapControllers() |> ignore) |> ignore -open Fc.Domain let build (args : Args.Arguments) = - let cache = Equinox.Cache(AppName, sizeMb = 10) + let cache = Equinox.Cache(AppName, sizeMb=10) let inventoryId = InventoryId.parse "FC000" - let maxTransactionsPerEpoch = 100 - let lookBackLimit = 10 - - let zeroBalance : Location.Epoch.Events.CarriedForward = { initial = 0; recentTransactions = [||] } - let chooseTransactionIds = function - | Location.Epoch.Fold.Init { recentTransactions = ids } -> Seq.ofArray ids - | Location.Epoch.Fold.Step { id = id } -> Seq.singleton id - let toBalanceCarriedForward (Location.Epoch.Fold.Current cur as records) : Location.Epoch.Events.CarriedForward = - { initial = cur; recentTransactions = records |> Seq.collect chooseTransactionIds |> Seq.truncate 5 |> Seq.toArray } - let shouldClose x = false - let maxAttempts = 3 - - let transactions, locations, inventory = - let es = args.Source - let connection = es.Connect(Log.Logger, Log.Logger, AppName, Equinox.EventStore.ConnectionStrategy.ClusterSingle Equinox.EventStore.NodePreference.Master) - let context = Equinox.EventStore.Context(connection, Equinox.EventStore.BatchingPolicy(maxBatchSize = 500)) - - let transactions = Inventory.Transaction.EventStore.create (context, cache) - let locations = Location.EventStore.create (zeroBalance, toBalanceCarriedForward, shouldClose) (context, cache, maxAttempts) - let inventory = Inventory.EventStore.create inventoryId (context, cache) - transactions, locations, inventory - Inventory.ProcessManager(transactions, locations, inventory) + + let es = args.Source + let connection = es.Connect(Log.Logger, Log.Logger, AppName, Equinox.EventStore.ConnectionStrategy.ClusterSingle Equinox.EventStore.NodePreference.Master) + let context = Equinox.EventStore.Context(connection, Equinox.EventStore.BatchingPolicy(maxBatchSize=500)) + Fc.Domain.StockProcessManager.EventStore.create inventoryId (1000, 5, 3) (context, cache) let run argv args = let processManager = build args From e6d514f302d899a3b954d5676b7520fd8855cd19 Mon Sep 17 00:00:00 2001 From: Ruben Bartelink Date: Fri, 12 Jun 2020 10:36:42 +0100 Subject: [PATCH 49/51] Tidy --- equinox-fc/Domain.Tests/LocationTests.fs | 16 ++++++++++------ equinox-fc/Domain/StockProcessManager.fs | 2 +- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/equinox-fc/Domain.Tests/LocationTests.fs b/equinox-fc/Domain.Tests/LocationTests.fs index ad543407f..2a0c39ec0 100644 --- a/equinox-fc/Domain.Tests/LocationTests.fs +++ b/equinox-fc/Domain.Tests/LocationTests.fs @@ -50,11 +50,13 @@ let run (service : Fc.Domain.Location.Service) (IdsAtLeastOne locations, deltas let! balances = seq { for loc in locations -> async { let! bal = service.Execute(loc,(fun (Epoch.Fold.Balance bal) -> bal, [])) in return loc,bal } } |> Async.Parallel test <@ expectedBalances = Set.ofSeq balances @> } -let [] ``MemoryStore properties`` maxEvents args = +let [] ``MemoryStore properties`` epochLen args = let store = Equinox.MemoryStore.VolatileStore() - let maxEvents = max 1 maxEvents - let service = Location.MemoryStore.create (Epoch.zeroBalance, Epoch.toBalanceCarriedForward 5, Epoch.shouldClose maxEvents) store + let epochLen, idsWindow = max 1 epochLen, 5 + let zero, cf, sc = Epoch.zeroBalance, Epoch.toBalanceCarriedForward idsWindow, Epoch.shouldClose epochLen + + let service = Location.MemoryStore.create (zero, cf, sc) store run service args type EventStore(testOutput) = @@ -64,7 +66,9 @@ type EventStore(testOutput) = let context, cache = EventStore.connect () - let [] properties maxEvents args = - let maxEvents = max 1 maxEvents - let service = Fc.Domain.Location.EventStore.create (Epoch.zeroBalance, Epoch.toBalanceCarriedForward 5, Epoch.shouldClose maxEvents) (context, cache, 50) + let [] properties epochLen args = + let epochLen, idsWindow = max 1 epochLen, 5 + let zero, cf, sc = Epoch.zeroBalance, Epoch.toBalanceCarriedForward idsWindow, Epoch.shouldClose epochLen + + let service = Fc.Domain.Location.EventStore.create (zero, cf, sc) (context, cache, 50) run service args diff --git a/equinox-fc/Domain/StockProcessManager.fs b/equinox-fc/Domain/StockProcessManager.fs index 8dea6e516..5bab583b4 100644 --- a/equinox-fc/Domain/StockProcessManager.fs +++ b/equinox-fc/Domain/StockProcessManager.fs @@ -50,7 +50,7 @@ module EventStore = let create inventoryId (epochLen, idsWindow, maxAttempts) (context, cache) = let transactions, locations, inventory = let transactions = StockTransaction.EventStore.create (context, cache) - let zero, cf, sc = (Location.Epoch.zeroBalance, Location.Epoch.toBalanceCarriedForward idsWindow, Location.Epoch.shouldClose epochLen) + let zero, cf, sc = Location.Epoch.zeroBalance, Location.Epoch.toBalanceCarriedForward idsWindow, Location.Epoch.shouldClose epochLen let locations = Location.EventStore.create (zero, cf, sc) (context, cache, maxAttempts) let inventory = Inventory.EventStore.create inventoryId (context, cache) transactions, locations, inventory From 0314a1602c3bd177acdb8009f1e30d4285676cad Mon Sep 17 00:00:00 2001 From: Ruben Bartelink Date: Fri, 12 Jun 2020 14:15:03 +0100 Subject: [PATCH 50/51] Cleanup from Cosmos --- equinox-fc/Domain.Tests/LocationTests.fs | 5 +++-- equinox-fc/Domain/StockProcessManager.fs | 2 +- equinox-fc/Web/Program.fs | 13 ++++++------- equinox-fc/Web/Web.fsproj | 1 - 4 files changed, 10 insertions(+), 11 deletions(-) diff --git a/equinox-fc/Domain.Tests/LocationTests.fs b/equinox-fc/Domain.Tests/LocationTests.fs index 2a0c39ec0..4e4a67645 100644 --- a/equinox-fc/Domain.Tests/LocationTests.fs +++ b/equinox-fc/Domain.Tests/LocationTests.fs @@ -5,6 +5,7 @@ open FSharp.UMX open Swensen.Unquote open System +open Fc.Domain open Fc.Domain.Location /// Helpers to match `module Cosmos/EventStore` wrapping inside the impl @@ -28,7 +29,7 @@ module Location = let epochs = Epoch.create (Epoch.resolve store) maxAttempts create (zeroBalance, toBalanceCarriedForward, shouldClose) (series, epochs) -let run (service : Fc.Domain.Location.Service) (IdsAtLeastOne locations, deltas : _[], transactionId) = Async.RunSynchronously <| async { +let run (service : Service) (IdsAtLeastOne locations, deltas : _[], transactionId) = Async.RunSynchronously <| async { let runId = mkId () // Need to make making state in store unique when replaying or shrinking let locations = locations |> Array.map (fun x -> % (sprintf "%O/%O" x runId)) @@ -70,5 +71,5 @@ type EventStore(testOutput) = let epochLen, idsWindow = max 1 epochLen, 5 let zero, cf, sc = Epoch.zeroBalance, Epoch.toBalanceCarriedForward idsWindow, Epoch.shouldClose epochLen - let service = Fc.Domain.Location.EventStore.create (zero, cf, sc) (context, cache, 50) + let service = Location.EventStore.create (zero, cf, sc) (context, cache, 50) run service args diff --git a/equinox-fc/Domain/StockProcessManager.fs b/equinox-fc/Domain/StockProcessManager.fs index 5bab583b4..4722c3c29 100644 --- a/equinox-fc/Domain/StockProcessManager.fs +++ b/equinox-fc/Domain/StockProcessManager.fs @@ -47,7 +47,7 @@ type Service(transactions : StockTransaction.Service, locations : Location.Servi module EventStore = - let create inventoryId (epochLen, idsWindow, maxAttempts) (context, cache) = + let create (context, cache) inventoryId (epochLen, idsWindow, maxAttempts) = let transactions, locations, inventory = let transactions = StockTransaction.EventStore.create (context, cache) let zero, cf, sc = Location.Epoch.zeroBalance, Location.Epoch.toBalanceCarriedForward idsWindow, Location.Epoch.shouldClose epochLen diff --git a/equinox-fc/Web/Program.fs b/equinox-fc/Web/Program.fs index d0da80df9..aa9562926 100644 --- a/equinox-fc/Web/Program.fs +++ b/equinox-fc/Web/Program.fs @@ -146,16 +146,15 @@ type Startup() = .UseEndpoints(fun endpoints -> endpoints.MapControllers() |> ignore) |> ignore - let build (args : Args.Arguments) = let cache = Equinox.Cache(AppName, sizeMb=10) - + let create = + let es = args.Source + let connection = es.Connect(Log.Logger, Log.Logger, AppName, Equinox.EventStore.ConnectionStrategy.ClusterSingle Equinox.EventStore.NodePreference.Master) + let context = Equinox.EventStore.Context(connection, Equinox.EventStore.BatchingPolicy(maxBatchSize=500)) + Fc.Domain.StockProcessManager.EventStore.create (context, cache) let inventoryId = InventoryId.parse "FC000" - - let es = args.Source - let connection = es.Connect(Log.Logger, Log.Logger, AppName, Equinox.EventStore.ConnectionStrategy.ClusterSingle Equinox.EventStore.NodePreference.Master) - let context = Equinox.EventStore.Context(connection, Equinox.EventStore.BatchingPolicy(maxBatchSize=500)) - Fc.Domain.StockProcessManager.EventStore.create inventoryId (1000, 5, 3) (context, cache) + create inventoryId (1000, 5, 3) let run argv args = let processManager = build args diff --git a/equinox-fc/Web/Web.fsproj b/equinox-fc/Web/Web.fsproj index 5ef2d9568..85146417a 100644 --- a/equinox-fc/Web/Web.fsproj +++ b/equinox-fc/Web/Web.fsproj @@ -13,7 +13,6 @@ - From d3d633804d03ac1dc651014309320ba2698959b0 Mon Sep 17 00:00:00 2001 From: Ruben Bartelink Date: Fri, 12 Jun 2020 16:48:25 +0100 Subject: [PATCH 51/51] Whitespace --- equinox-fc/Domain/InventoryEpoch.fs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/equinox-fc/Domain/InventoryEpoch.fs b/equinox-fc/Domain/InventoryEpoch.fs index 21bd6562a..eff0cb5a8 100644 --- a/equinox-fc/Domain/InventoryEpoch.fs +++ b/equinox-fc/Domain/InventoryEpoch.fs @@ -71,4 +71,4 @@ module EventStore = let create (context, cache) = let cacheStrategy = CachingStrategy.SlidingWindow (cache, System.TimeSpan.FromMinutes 20.) let resolver = Resolver(context, Events.codec, Fold.fold, Fold.initial, cacheStrategy) - create resolver.Resolve \ No newline at end of file + create resolver.Resolve