diff --git a/EventSourcing.NetCore.sln b/EventSourcing.NetCore.sln index 1bd6322c0..302298d2b 100644 --- a/EventSourcing.NetCore.sln +++ b/EventSourcing.NetCore.sln @@ -477,6 +477,20 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SwaggerUi.Aspire.Hosting", EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Core.Scheduling", "Core.Scheduling\Core.Scheduling.csproj", "{3B754142-CDA4-430F-8DF7-5DC0D204EE35}" EndProject +Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "ECommerce.Domain", "Sample\ECommerce.Equinox\ECommerce.Domain\ECommerce.Domain.fsproj", "{7C22079D-C359-40DE-8E5F-1DDD9CDD06DD}" +EndProject +Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "ECommerce.Reactor", "Sample\ECommerce.Equinox\ECommerce.Reactor\ECommerce.Reactor.fsproj", "{C3D0553F-5786-4417-97FD-B65440620274}" +EndProject +Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "ECommerce.Api", "Sample\ECommerce.Equinox\ECommerce.Api\ECommerce.Api.fsproj", "{1F43CF34-CD69-4EEB-BF64-5AD649CE3AF5}" +EndProject +Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "ECommerce.Infrastructure", "Sample\ECommerce.Equinox\ECommerce.Infrastructure\ECommerce.Infrastructure.fsproj", "{1C310D3A-BFC5-4984-BB11-4579067CFFB5}" +EndProject +Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "ECommerce.Tests", "Sample\ECommerce.Equinox\ECommerce.Tests\ECommerce.Tests.fsproj", "{16B7A55B-E2E6-4CE2-896A-EF0F02259482}" +EndProject +Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "ECommerce.FeedConsumer", "Sample\ECommerce.Equinox\ECommerce.FeedConsumer\ECommerce.FeedConsumer.fsproj", "{F2D522B1-BDF0-4345-919D-42DD79D4BB24}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ECommerce.Equinox", "ECommerce.Equinox", "{006643C6-E0B6-48E6-ABC6-9BE3DCB293D8}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -1079,6 +1093,18 @@ Global {3B754142-CDA4-430F-8DF7-5DC0D204EE35}.Debug|Any CPU.Build.0 = Debug|Any CPU {3B754142-CDA4-430F-8DF7-5DC0D204EE35}.Release|Any CPU.ActiveCfg = Release|Any CPU {3B754142-CDA4-430F-8DF7-5DC0D204EE35}.Release|Any CPU.Build.0 = Release|Any CPU + {1F43CF34-CD69-4EEB-BF64-5AD649CE3AF5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1F43CF34-CD69-4EEB-BF64-5AD649CE3AF5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7C22079D-C359-40DE-8E5F-1DDD9CDD06DD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7C22079D-C359-40DE-8E5F-1DDD9CDD06DD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F2D522B1-BDF0-4345-919D-42DD79D4BB24}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F2D522B1-BDF0-4345-919D-42DD79D4BB24}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1C310D3A-BFC5-4984-BB11-4579067CFFB5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1C310D3A-BFC5-4984-BB11-4579067CFFB5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C3D0553F-5786-4417-97FD-B65440620274}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C3D0553F-5786-4417-97FD-B65440620274}.Debug|Any CPU.Build.0 = Debug|Any CPU + {16B7A55B-E2E6-4CE2-896A-EF0F02259482}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {16B7A55B-E2E6-4CE2-896A-EF0F02259482}.Debug|Any CPU.Build.0 = Debug|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -1276,6 +1302,13 @@ Global {D4CD0C59-65CC-4051-A77E-2C944F3C27C0} = {A8E25331-55E9-4D1A-87A4-136EC4D2A4B5} {CCFE487A-A5C5-4DAC-8815-AC486B9625BC} = {0570E45A-2EB6-4C4C-84E4-2C80E1FECEB5} {3B754142-CDA4-430F-8DF7-5DC0D204EE35} = {0570E45A-2EB6-4C4C-84E4-2C80E1FECEB5} + {006643C6-E0B6-48E6-ABC6-9BE3DCB293D8} = {A7186B6B-D56D-4AEF-B6B7-FAA827764C34} + {1F43CF34-CD69-4EEB-BF64-5AD649CE3AF5} = {006643C6-E0B6-48E6-ABC6-9BE3DCB293D8} + {7C22079D-C359-40DE-8E5F-1DDD9CDD06DD} = {006643C6-E0B6-48E6-ABC6-9BE3DCB293D8} + {F2D522B1-BDF0-4345-919D-42DD79D4BB24} = {006643C6-E0B6-48E6-ABC6-9BE3DCB293D8} + {1C310D3A-BFC5-4984-BB11-4579067CFFB5} = {006643C6-E0B6-48E6-ABC6-9BE3DCB293D8} + {C3D0553F-5786-4417-97FD-B65440620274} = {006643C6-E0B6-48E6-ABC6-9BE3DCB293D8} + {16B7A55B-E2E6-4CE2-896A-EF0F02259482} = {006643C6-E0B6-48E6-ABC6-9BE3DCB293D8} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {A5F55604-2FF3-43B7-B657-4F18E6E95D3B} diff --git a/Sample/ECommerce.Equinox/Directory.Build.props b/Sample/ECommerce.Equinox/Directory.Build.props new file mode 100644 index 000000000..398982871 --- /dev/null +++ b/Sample/ECommerce.Equinox/Directory.Build.props @@ -0,0 +1,6 @@ + + + + true + + diff --git a/Sample/ECommerce.Equinox/ECommerce.Api/Controllers/ConfirmedFeedController.fs b/Sample/ECommerce.Equinox/ECommerce.Api/Controllers/ConfirmedFeedController.fs new file mode 100644 index 000000000..6fc9012c4 --- /dev/null +++ b/Sample/ECommerce.Equinox/ECommerce.Api/Controllers/ConfirmedFeedController.fs @@ -0,0 +1,59 @@ +namespace ECommerce.Api.Controllers + +open Microsoft.AspNetCore.Mvc + +open ECommerce.Domain + +type TranchesDto = { activeEpochs : TrancheReferenceDto[] } + and TrancheReferenceDto = { seriesId : ConfirmedSeriesId; epochId : ConfirmedEpochId } + +module TranchesDto = + + let ofEpochId epochId = + { activeEpochs = [| { seriesId = ConfirmedSeriesId.wellKnownId; epochId = epochId } |]} + +type SliceDto = { closed : bool; carts : CartDto[]; position : ConfirmedCheckpoint; checkpoint : ConfirmedCheckpoint } + and CartDto = { id : CartId; items : ItemDto[] } + and ItemDto = { productId : ProductId; unitPrice : decimal; quantity : int } + +module CartDto = + + let ofDto (x : ConfirmedEpoch.Events.Cart) : CartDto = + { id = x.cartId + items = [| for x in x.items -> { productId = x.productId; unitPrice = x.unitPrice; quantity = x.quantity } |] } + +module Checkpoint = + + let ofEpochAndOffset (epoch : ConfirmedEpochId) (offset : int) = + ConfirmedCheckpoint.ofEpochAndOffset epoch offset + + let ofState (epochId : ConfirmedEpochId) (s : ConfirmedEpoch.Reader.StateDto) = + ConfirmedCheckpoint.ofEpochContent epochId s.closed s.carts.Length + +[] +type ConfirmedFeedController(series : ConfirmedSeries.Service, epochs : ConfirmedEpoch.Reader.Service) = + inherit ControllerBase() + + [] + member _.ListTranches() : Async = async { + let! active = series.ReadIngestionEpochId() + return TranchesDto.ofEpochId active + } + + [] + member _.ReadTranche(epoch : ConfirmedEpochId) : Async = async { + let! state = epochs.Read(epoch) + // TOCONSIDER closed should control cache header + let pos, checkpoint = Checkpoint.ofEpochAndOffset epoch 0, Checkpoint.ofState epoch state + return { closed = state.closed; carts = Array.map CartDto.ofDto state.carts; position = pos; checkpoint = checkpoint } + } + + [] + member _.Poll(token : System.Nullable) : Async = async { + let pos = if token.HasValue then token.Value else ConfirmedCheckpoint.initial + let epochId, offset = ConfirmedCheckpoint.toEpochAndOffset pos + let! state = epochs.Read(epochId) + // TOCONSIDER closed should control cache header + let pos, checkpoint = Checkpoint.ofEpochAndOffset epochId offset, Checkpoint.ofState epochId state + return { closed = state.closed; carts = Array.skip offset state.carts |> Array.map CartDto.ofDto; position = pos; checkpoint = checkpoint } + } diff --git a/Sample/ECommerce.Equinox/ECommerce.Api/Controllers/ShoppingCartsController.fs b/Sample/ECommerce.Equinox/ECommerce.Api/Controllers/ShoppingCartsController.fs new file mode 100644 index 000000000..2fecb7de0 --- /dev/null +++ b/Sample/ECommerce.Equinox/ECommerce.Api/Controllers/ShoppingCartsController.fs @@ -0,0 +1,85 @@ +namespace ECommerce.Api.Controllers + +open ECommerce.Domain +open Microsoft.AspNetCore.Mvc +open System + +type InitializeShoppingCartRequest = { clientId : Guid Nullable } +type AddProductRequest = { productId : Guid; quantity : int } +type RemoveProductRequest = { productId : Guid; price : decimal } + +[] +[] +type ShoppingCartsController(carts : ShoppingCart.Service, cartsDenormalized : ShoppingCartSummary.Service) = + inherit ControllerBase() + + [] + member _.InitializeCart([] request : InitializeShoppingCartRequest) : Async = async { + if obj.ReferenceEquals(null, request) then nameof request |> nullArg + + // TODO in these samples in general, the semantics should be extended to be more representative of the real world + // - you don't want to create orphan carts esp if they need to retry this API call + // - you may want to allow users to shop before logging in, keeping the clientId in a cookie. + // After some time, you let them log in, but then you need to merge the content into their real cart + // For now, the code remains in line with the C# version + let cartId = CartId.generate(); + do! carts.Initialize(cartId, ClientId.parse request.clientId) + return CreatedResult("api/ShoppingCarts", cartId) :> _ + } + + [] + member _.AddProduct([] id : Guid Nullable, [] request : AddProductRequest) : Async = async { + if obj.ReferenceEquals(null, request) then nameof request |> nullArg + + let CartId.ParseGuid cartId, ProductId.Parse productId = id, request.productId + do! carts.Add(cartId, productId, request.quantity) + return OkResult() :> _ + } + + [] + member _.RemoveProduct([] id : Guid Nullable, [] request : RemoveProductRequest) : Async = async { + if obj.ReferenceEquals(null, request) then nameof request |> nullArg + + let CartId.ParseGuid cartId, ProductId.Parse productId = id, request.productId + do! carts.Remove(cartId, productId, request.price) + return OkResult() :> _ + } + + [] + member _.ConfirmCart([] id : Guid Nullable(*, [] request : ConfirmShoppingCartRequest*)) : Async = async { +// if obj.ReferenceEquals(null, request) then nameof request |> nullArg // TODO only relevant if we follow version-contingent style + + let (CartId.ParseGuid cartId) = id + do! carts.Confirm(cartId, DateTimeOffset.UtcNow) + return OkResult() :> _ + } + + /// Reads from write side + [] + member _.Get([] id : Guid Nullable) : Async = async { + let (CartId.ParseGuid cartId) = id + match! carts.Read cartId with + | Some (res : ShoppingCart.Details.View) -> return OkObjectResult res :> _ + | None -> return NotFoundResult() :> _ + } + + /// Reads from denormalized view + [] + member _.GetSummary([] id : Guid Nullable) : Async = async { + let (CartId.ParseGuid cartId) = id + match! cartsDenormalized.Read cartId with + | Some (res : ShoppingCartSummary.Details.View) -> return OkObjectResult res :> _ + | None -> return NotFoundResult() :> _ + } + + (* TODO we dont produce a list like this atm - not porting for the moment as having an arbitrarily growing list like this does not really make + sense; instead, we'll produce a cart summaries API + [HttpGet] + public Task> Get( + [FromServices] Func>> query, + CancellationToken ct, + [FromQuery] int pageNumber = 1, + [FromQuery] int pageSize = 20 + ) => + query(GetCarts.From(pageNumber, pageSize), ct); +*) diff --git a/Sample/ECommerce.Equinox/ECommerce.Api/ECommerce.Api.fsproj b/Sample/ECommerce.Equinox/ECommerce.Api/ECommerce.Api.fsproj new file mode 100644 index 000000000..37076391f --- /dev/null +++ b/Sample/ECommerce.Equinox/ECommerce.Api/ECommerce.Api.fsproj @@ -0,0 +1,28 @@ + + + + net6.0 + + + + + + + + + + + + + + + + + + + + + <_ContentIncludedByDefault Remove="Properties\launchSettings.json" /> + + + diff --git a/Sample/ECommerce.Equinox/ECommerce.Api/Program.fs b/Sample/ECommerce.Equinox/ECommerce.Api/Program.fs new file mode 100644 index 000000000..4f22e1837 --- /dev/null +++ b/Sample/ECommerce.Equinox/ECommerce.Api/Program.fs @@ -0,0 +1,82 @@ +module ECommerce.Api.Program + +open ECommerce +open Microsoft.AspNetCore.Hosting +open Microsoft.Extensions.DependencyInjection +open Serilog +open System + +type Configuration(tryGet) = + inherit Args.Configuration(tryGet) + +let [] AppName = "ECommerce.Web" + +module Args = + + open Argu + + type [] Parameters = + | [] Verbose + | [] PrometheusPort of int + | [] Cosmos of ParseResults + | [] Dynamo of ParseResults + | [] Esdb of ParseResults + | [] Sss of ParseResults + interface IArgParserTemplate with + member a.Usage = a |> function + | Verbose -> "request verbose logging." + | PrometheusPort _ -> "port from which to expose a Prometheus /metrics endpoint. Default: off (optional if environment variable PROMETHEUS_PORT specified)" + | Cosmos _ -> "specify CosmosDB input parameters" + | Dynamo _ -> "specify DynamoDB input parameters" + | Esdb _ -> "specify EventStore input parameters" + | Sss _ -> "specify SqlStreamStore input parameters" + and [] + Arguments(c : Configuration, p : ParseResults) = + member val Verbose = p.Contains Verbose + member val PrometheusPort = p.TryGetResult PrometheusPort |> Option.orElseWith (fun () -> c.PrometheusPort) + member val CacheSizeMb = 10 + member val StoreArgs : Args.StoreArgs = + match p.TryGetSubCommand() with + | Some (Parameters.Cosmos cosmos) -> Args.StoreArgs.Cosmos (Args.Cosmos.Arguments(c, cosmos)) + | Some (Parameters.Dynamo dynamo) -> Args.StoreArgs.Dynamo (Args.Dynamo.Arguments(c, dynamo)) + | Some (Parameters.Esdb es) -> Args.StoreArgs.Esdb (Args.Esdb.Arguments(c, es)) + | Some (Parameters.Sss sss) -> Args.StoreArgs.Sss (Args.Sss.Arguments(c, sss)) + | _ -> p.Raise "Must specify one of cosmos, dynamo, esdb or sss for store" + member x.VerboseStore = Args.StoreArgs.verboseRequested x.StoreArgs + member x.Connect(): Store.Config = + let cache = Equinox.Cache (AppName, sizeMb = x.CacheSizeMb) + Args.StoreArgs.connectTarget x.StoreArgs cache + + /// Parse the commandline; can throw exceptions in response to missing arguments and/or `-h`/`--help` args + let parse tryGetConfigValue argv = + let programName = Reflection.Assembly.GetEntryAssembly().GetName().Name + let parser = ArgumentParser.Create(programName = programName) + Arguments(Configuration tryGetConfigValue, parser.ParseCommandLine argv) + +let run (args : Args.Arguments) = + let store = args.Connect() + let carts = Domain.ShoppingCart.Config.create store + let registerServices (services: IServiceCollection) = + services.AddSingleton(carts) |> ignore + WebHostBuilder() + .UseKestrel() + .UseSerilog() + .ConfigureServices(registerServices) + .UseStartup() + .Build() + .Run() + +[] +let main argv = + try let args = Args.parse EnvVar.tryGet argv + let metrics = Sinks.tags AppName |> Sinks.equinoxMetricsOnly + try Log.Logger <- LoggerConfiguration() + .Configure(args.Verbose) + .MinimumLevel.Override("Microsoft.AspNetCore", Serilog.Events.LogEventLevel.Warning) + .Sinks(metrics, args.VerboseStore) + .CreateLogger() + try run args; 0 + with e -> Log.Fatal(e, "Exiting"); 2 + finally Log.CloseAndFlush() + with:? Argu.ArguParseException as e -> eprintfn $"%s{e.Message}"; 1 + | e -> eprintfn $"Exception %s{e.Message}"; 1 diff --git a/Sample/ECommerce.Equinox/ECommerce.Api/Startup.fs b/Sample/ECommerce.Equinox/ECommerce.Api/Startup.fs new file mode 100644 index 000000000..718752888 --- /dev/null +++ b/Sample/ECommerce.Equinox/ECommerce.Api/Startup.fs @@ -0,0 +1,31 @@ +namespace ECommerce.Api + +open Microsoft.AspNetCore.Builder +open Microsoft.Extensions.DependencyInjection +open Microsoft.Extensions.Hosting +open Prometheus +open Serilog + +type Startup() = + + member _.ConfigureServices(services : IServiceCollection) : unit = + services.AddMvc() |> ignore + services.AddControllers() + .AddNewtonsoftJson() |> ignore + // TODO AddSwaggerGen + + // 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 + + app + .UseRouting() + //.UseAuthorization() + .UseSerilogRequestLogging() // see https://nblumhardt.com/2019/10/serilog-in-aspnetcore-3/ + .UseEndpoints(fun endpoints -> + endpoints.MapControllers() |> ignore + endpoints.MapMetrics() |> ignore) + |> ignore +// app.UseSwagger(); +// app.UseSwaggerUI diff --git a/Sample/ECommerce.Equinox/ECommerce.Domain/Config.fs b/Sample/ECommerce.Equinox/ECommerce.Domain/Config.fs new file mode 100644 index 000000000..28aa7b952 --- /dev/null +++ b/Sample/ECommerce.Equinox/ECommerce.Domain/Config.fs @@ -0,0 +1,85 @@ +module ECommerce.Domain.Config + +let log = Serilog.Log.ForContext("isMetric", true) +let createDecider category = Equinox.Decider.resolve log category + +module Memory = + + let create codec initial fold store : Equinox.Category<_, _, _> = + Equinox.MemoryStore.MemoryStoreCategory(store, codec, fold, initial) + +module EventCodec = + + open FsCodec.SystemTextJson + + let private defaultOptions = Options.Create(autoTypeSafeEnumToJsonString = true) + let genJsonElement<'t when 't :> TypeShape.UnionContract.IUnionContract> = + CodecJsonElement.Create<'t>(options = defaultOptions) + let gen<'t when 't :> TypeShape.UnionContract.IUnionContract> = + Codec.Create<'t>(options = defaultOptions) + +let private defaultCacheDuration = System.TimeSpan.FromMinutes 20. + +module Cosmos = + + let private createCached codec initial fold accessStrategy (context, cache) = + let cacheStrategy = Equinox.CosmosStore.CachingStrategy.SlidingWindow (cache, defaultCacheDuration) + Equinox.CosmosStore.CosmosStoreCategory(context, codec, fold, initial, cacheStrategy, accessStrategy) + + let createUnoptimized codec initial fold (context, cache) = + let accessStrategy = Equinox.CosmosStore.AccessStrategy.Unoptimized + createCached codec initial fold accessStrategy (context, cache) + + let createSnapshotted codec initial fold (isOrigin, toSnapshot) (context, cache) = + let accessStrategy = Equinox.CosmosStore.AccessStrategy.Snapshot (isOrigin, toSnapshot) + createCached codec initial fold accessStrategy (context, cache) + + let createRollingState codec initial fold toSnapshot (context, cache) = + let accessStrategy = Equinox.CosmosStore.AccessStrategy.RollingState toSnapshot + createCached codec initial fold accessStrategy (context, cache) + +module Dynamo = + + let private createCached codec initial fold accessStrategy (context, cache) = + let cacheStrategy = Equinox.DynamoStore.CachingStrategy.SlidingWindow (cache, defaultCacheDuration) + Equinox.DynamoStore.DynamoStoreCategory(context, codec |> FsCodec.Deflate.EncodeUncompressed, fold, initial, cacheStrategy, accessStrategy) + + let createUnoptimized codec initial fold (context, cache) = + let accessStrategy = Equinox.DynamoStore.AccessStrategy.Unoptimized + createCached codec initial fold accessStrategy (context, cache) + + let createSnapshotted codec initial fold (isOrigin, toSnapshot) (context, cache) = + let accessStrategy = Equinox.DynamoStore.AccessStrategy.Snapshot (isOrigin, toSnapshot) + createCached codec initial fold accessStrategy (context, cache) + + let createRollingState codec initial fold toSnapshot (context, cache) = + let accessStrategy = Equinox.DynamoStore.AccessStrategy.RollingState toSnapshot + createCached codec initial fold accessStrategy (context, cache) + +module Esdb = + + let private createCached codec initial fold accessStrategy (context, cache) = + let cacheStrategy = Equinox.EventStoreDb.CachingStrategy.SlidingWindow (cache, defaultCacheDuration) + Equinox.EventStoreDb.EventStoreCategory(context, codec, fold, initial, cacheStrategy, ?access = accessStrategy) + let createUnoptimized codec initial fold (context, cache) = + createCached codec initial fold None (context, cache) + let createLatestKnownEvent codec initial fold (context, cache) = + createCached codec initial fold (Some Equinox.EventStoreDb.AccessStrategy.LatestKnownEvent) (context, cache) + +module Sss = + + let private createCached codec initial fold accessStrategy (context, cache) = + let cacheStrategy = Equinox.SqlStreamStore.CachingStrategy.SlidingWindow (cache, defaultCacheDuration) + Equinox.SqlStreamStore.SqlStreamStoreCategory(context, codec, fold, initial, cacheStrategy, ?access = accessStrategy) + let createUnoptimized codec initial fold (context, cache) = + createCached codec initial fold None (context, cache) + let createLatestKnownEvent codec initial fold (context, cache) = + createCached codec initial fold (Some Equinox.SqlStreamStore.AccessStrategy.LatestKnownEvent) (context, cache) + +[] +type Store<'t> = + | Memory of Equinox.MemoryStore.VolatileStore<'t> + | Cosmos of Equinox.CosmosStore.CosmosStoreContext * Equinox.Core.ICache + | Dynamo of Equinox.DynamoStore.DynamoStoreContext * Equinox.Core.ICache + | Esdb of Equinox.EventStoreDb.EventStoreContext * Equinox.Core.ICache + | Sss of Equinox.SqlStreamStore.SqlStreamStoreContext * Equinox.Core.ICache diff --git a/Sample/ECommerce.Equinox/ECommerce.Domain/ConfirmedEpoch.fs b/Sample/ECommerce.Equinox/ECommerce.Domain/ConfirmedEpoch.fs new file mode 100644 index 000000000..5de89457c --- /dev/null +++ b/Sample/ECommerce.Equinox/ECommerce.Domain/ConfirmedEpoch.fs @@ -0,0 +1,124 @@ +/// Tracks all Confirmed Carts in the system +/// - Used to walk back through the history of all Carts in the system in approximate order of their processing +/// - Limited to a certain reasonable count of items; snapshot of Items in an epoch needs to stay a sensible size +/// The ConfirmedSeries holds a pointer to the current active epoch for each series +/// Each successive epoch is identified by an index, i.e. ConfirmedEpoch-0_0, then ConfirmedEpoch-0_1 +module ECommerce.Domain.ConfirmedEpoch + +let [] CategoryName = "ConfirmedEpoch" +let streamId epochId = FsCodec.StreamId.gen2 ConfirmedSeriesId.toString ConfirmedEpochId.toString (ConfirmedSeriesId.wellKnownId, epochId) + +// NB - these types and the union case names reflect the actual storage formats and hence need to be versioned with care +[] +module Events = + + type Ingested = { carts : Cart[] } + and Cart = { cartId : CartId; items : Item[] } + and Item = { productId : ProductId; unitPrice : decimal; quantity : int } + type Event = + | Ingested of Ingested + | Closed + interface TypeShape.UnionContract.IUnionContract + let codec = Store.Codec.gen + let codecJsonElement = Store.Codec.genJsonElement + +let ofShoppingCartView cartId (view : ShoppingCart.Details.View) : Events.Cart = + { cartId = cartId; items = [| for i in view.items -> { productId = i.productId; unitPrice = i.unitPrice; quantity = i.quantity } |] } + +let itemId (x : Events.Cart) : CartId = x.cartId +let (|ItemIds|) : Events.Cart[] -> CartId[] = Array.map itemId + +module Fold = + + type State = CartId[] * bool + let initial = [||], false + let evolve (ids, closed) = function + | Events.Ingested { carts = ItemIds ingestedIds } -> (Array.append ids ingestedIds, closed) + | Events.Closed -> (ids, true) + + let fold : State -> Events.Event seq -> State = Seq.fold evolve + +let notAlreadyIn (ids : CartId seq) = + let ids = System.Collections.Generic.HashSet ids + fun (x : Events.Cart) -> (not << ids.Contains) x.cartId + +/// Manages ingestion of only items not already in the list +/// Yields residual net of items already present in this epoch +// NOTE See feedSource template for more advanced version handling splitting large input requests where epoch limit is strict +let decide shouldClose candidates (currentIds, closed as state) : ExactlyOnceIngester.IngestResult<_,_> * Events.Event[] = + match closed, candidates |> Array.filter (notAlreadyIn currentIds) with + | false, fresh -> + let added, events = + match fresh with + | [||] -> [||], [||] + | ItemIds freshIds -> + let closing = shouldClose currentIds freshIds + let ingestEvent = Events.Ingested { carts = fresh } + freshIds, if closing then [| ingestEvent ; Events.Closed |] else [| ingestEvent |] + let _, closed = Fold.fold state events + { accepted = added; closed = closed; residual = [||] }, events + | true, fresh -> + { accepted = [||]; closed = true; residual = fresh }, [||] + +// NOTE see feedSource for example of separating Service logic into Ingestion and Read Services in order to vary the folding and/or state held +type Service internal + ( shouldClose : CartId[] -> CartId[] -> bool, // let outer layers decide whether ingestion should trigger closing of the batch + resolve : ConfirmedEpochId -> Equinox.Decider) = + + /// Ingest the supplied items. Yields relevant elements of the post-state to enable generation of stats + /// and facilitate deduplication of incoming items in order to avoid null store round-trips where possible + member _.Ingest(epochId, carts) = + let decider = resolve epochId + // NOTE decider which will initially transact against potentially stale cached state, which will trigger a + // resync if another writer has gotten in before us. This is a conscious decision in this instance; the bulk + // of writes are presumed to be coming from within this same process + decider.Transact(decide shouldClose carts, load = Equinox.LoadOption.AnyCachedValue) + + /// Returns all the items currently held in the stream (Not using AllowStale on the assumption this needs to see updates from other apps) + member _.Read epochId : Async = + let decider = resolve epochId + decider.Query id + +module Config = + + let private create_ shouldClose cat = Service(shouldClose, streamId >> Store.createDecider cat) + let private (|Category|) = function + | Store.Config.Memory store -> Store.Memory.create CategoryName Events.codec Fold.initial Fold.fold store + | Store.Config.Cosmos (context, cache) -> Store.Cosmos.createUnoptimized CategoryName Events.codecJsonElement Fold.initial Fold.fold (context, cache) + | Store.Config.Dynamo (context, cache) -> Store.Dynamo.createUnoptimized CategoryName Events.codec Fold.initial Fold.fold (context, cache) + | Store.Config.Esdb (context, cache) -> Store.Esdb.createUnoptimized CategoryName Events.codec Fold.initial Fold.fold (context, cache) + | Store.Config.Sss (context, cache) -> Store.Sss.createUnoptimized CategoryName Events.codec Fold.initial Fold.fold (context, cache) + let shouldClose maxItemsPerEpoch candidateItems currentItems = Array.length currentItems + Array.length candidateItems >= maxItemsPerEpoch + let create maxItemsPerEpoch (Category cat) = create_ (shouldClose maxItemsPerEpoch) cat + +/// Custom Fold and caching logic compared to the IngesterService +/// - When reading, we want the full Items +/// - Caching only for one minute +/// - There's no value in using the snapshot as it does not have the full state +module Reader = + + type State = Events.Cart[] * bool + let initial = [||], false + let evolve (es, closed) = function + | Events.Ingested e -> Array.append es e.carts, closed + | Events.Closed -> (es, true) + let fold : State -> Events.Event seq -> State = Seq.fold evolve + + type StateDto = { closed : bool; carts : Events.Cart[] } + + type Service internal (resolve : ConfirmedEpochId -> Equinox.Decider) = + + /// Returns all the items currently held in the stream + member _.Read(epochId) : Async = + let decider = resolve epochId + decider.Query(fun (carts, closed) -> { closed = closed; carts = carts }) + + module Config = + + let private (|Category|) = function + | Store.Config.Memory store -> Store.Memory.create CategoryName Events.codec initial fold store + | Store.Config.Cosmos (context, cache) -> Store.Cosmos.createUnoptimized CategoryName Events.codecJsonElement initial fold (context, cache) + | Store.Config.Dynamo (context, cache) -> Store.Dynamo.createUnoptimized CategoryName Events.codec initial fold (context, cache) + | Store.Config.Esdb (context, cache) -> Store.Esdb.createUnoptimized CategoryName Events.codec initial fold (context, cache) + | Store.Config.Sss (context, cache) -> Store.Sss.createUnoptimized CategoryName Events.codec initial fold (context, cache) + let create (Category cat) = Service(streamId >> Store.createDecider cat) diff --git a/Sample/ECommerce.Equinox/ECommerce.Domain/ConfirmedIngester.fs b/Sample/ECommerce.Equinox/ECommerce.Domain/ConfirmedIngester.fs new file mode 100644 index 000000000..6d719b2a8 --- /dev/null +++ b/Sample/ECommerce.Equinox/ECommerce.Domain/ConfirmedIngester.fs @@ -0,0 +1,28 @@ +module ECommerce.Domain.ConfirmedIngester + +type Service internal (tip : ExactlyOnceIngester.Service<_, _, _, _>) = + + member _.IngestMany(originEpochId, cartSummaries) : Async = + tip.IngestMany(originEpochId, cartSummaries) + + /// Slot the item into the series of epochs. + /// Returns true if it got added this time, i.e. idempotent retries don't count + member x.TryIngestCartSummary(originEpochId, cartSummary : ConfirmedEpoch.Events.Cart) : Async = async { + let! ingested = x.IngestMany(originEpochId, [| cartSummary |]) + return ingested |> Seq.contains cartSummary.cartId } + + /// Efficiently determine a valid ingestion origin epoch + member _.ActiveIngestionEpochId() = + tip.ActiveIngestionEpochId() + +module Config = + + let create_ maxItemsPerEpoch linger store = + let series = ConfirmedSeries.Config.create store + let epochs = ConfirmedEpoch.Config.create maxItemsPerEpoch store + let log = Serilog.Log.ForContext() + let tip = ExactlyOnceIngester.create log linger (series.ReadIngestionEpochId, series.MarkIngestionEpochId) (epochs.Ingest, Array.toSeq) + Service(tip) + let create linger store = + let maxItemsPerEpoch = 10_000 + create_ maxItemsPerEpoch linger store diff --git a/Sample/ECommerce.Equinox/ECommerce.Domain/ConfirmedSeries.fs b/Sample/ECommerce.Equinox/ECommerce.Domain/ConfirmedSeries.fs new file mode 100644 index 000000000..68d3d24e8 --- /dev/null +++ b/Sample/ECommerce.Equinox/ECommerce.Domain/ConfirmedSeries.fs @@ -0,0 +1,60 @@ +/// Maintains a pointer into the Epoch chain for a given Series +/// Allows the Ingester to determine the current Epoch into which it commence writing via ReadIngestionEpochId +/// As an Epoch is marked `Closed`, the Ingester will mark a new Epoch `Started` on this aggregate via MarkIngestionEpochId +module ECommerce.Domain.ConfirmedSeries + +let [] CategoryName = "ConfirmedSeries" +// TOCONSIDER: if you need multiple lists series/epochs in a single system, the Series and Epoch streams should have a SeriesId in the stream name +// See also the implementation in the feedSource template, where the Series aggregate also functions as an index of series held in the system +let streamId () = FsCodec.StreamId.gen ConfirmedSeriesId.toString ConfirmedSeriesId.wellKnownId + +// NB - these types and the union case names reflect the actual storage formats and hence need to be versioned with care +[] +module Events = + + type Event = + | Started of {| epochId : ConfirmedEpochId |} + | Snapshotted of {| active : ConfirmedEpochId |} + interface TypeShape.UnionContract.IUnionContract + let codec = Store.Codec.gen + let codecJsonElement = Store.Codec.genJsonElement + +module Fold = + + type State = ConfirmedEpochId option + let initial = None + let private evolve _state = function + | Events.Started e -> Some e.epochId + | Events.Snapshotted e -> Some e.active + let fold : State -> Events.Event seq -> State = Seq.fold evolve + + let isOrigin = function Events.Snapshotted _ -> true | _ -> false + let toSnapshot s = Events.Snapshotted {| active = Option.get s |} + +let interpret epochId (state : Fold.State) = [| + if state |> Option.forall (fun cur -> cur < epochId) && epochId >= ConfirmedEpochId.initial then + yield Events.Started {| epochId = epochId |}|] + +type Service internal (resolve : unit -> Equinox.Decider) = + + /// Determines the current active epoch + /// Uses cached values as epoch transitions are rare, and caller needs to deal with the inherent race condition in any case + member _.ReadIngestionEpochId() : Async = + let decider = resolve () + decider.Query(Option.defaultValue ConfirmedEpochId.initial) + + /// Mark specified `epochId` as live for the purposes of ingesting + /// Writers are expected to react to having writes to an epoch denied (due to it being Closed) by anointing a successor via this + member _.MarkIngestionEpochId epochId : Async = + let decider = resolve () + decider.Transact(interpret epochId, load = Equinox.LoadOption.AnyCachedValue) + +module Config = + + let private (|Category|) = function + | Store.Config.Memory store -> Store.Memory.create CategoryName Events.codec Fold.initial Fold.fold store + | Store.Config.Cosmos (context, cache) -> Store.Cosmos.createSnapshotted CategoryName Events.codecJsonElement Fold.initial Fold.fold (Fold.isOrigin, Fold.toSnapshot) (context, cache) + | Store.Config.Dynamo (context, cache) -> Store.Dynamo.createSnapshotted CategoryName Events.codec Fold.initial Fold.fold (Fold.isOrigin, Fold.toSnapshot) (context, cache) + | Store.Config.Esdb (context, cache) -> Store.Esdb.createUnoptimized CategoryName Events.codec Fold.initial Fold.fold (context, cache) + | Store.Config.Sss (context, cache) -> Store.Sss.createUnoptimized CategoryName Events.codec Fold.initial Fold.fold (context, cache) + let create (Category cat) = Service(streamId >> Store.createDecider cat) diff --git a/Sample/ECommerce.Equinox/ECommerce.Domain/ECommerce.Domain.fsproj b/Sample/ECommerce.Equinox/ECommerce.Domain/ECommerce.Domain.fsproj new file mode 100644 index 000000000..537e5fd1e --- /dev/null +++ b/Sample/ECommerce.Equinox/ECommerce.Domain/ECommerce.Domain.fsproj @@ -0,0 +1,31 @@ + + + + net6.0 + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Sample/ECommerce.Equinox/ECommerce.Domain/ExactlyOnceIngester.fs b/Sample/ECommerce.Equinox/ECommerce.Domain/ExactlyOnceIngester.fs new file mode 100644 index 000000000..12d7dba82 --- /dev/null +++ b/Sample/ECommerce.Equinox/ECommerce.Domain/ExactlyOnceIngester.fs @@ -0,0 +1,89 @@ +/// Generic Service that controls the deterministic application of requests that insert items into a chain of `Epoch` streams +/// - the `Series` aggregate maintains a pointer to the current Epoch +/// - as `Epoch`s complete (have `Closed` events logged), we update the `active` Epoch in the Series to reference the new one +/// The fact that each request walks forward from a given start point until it either gets to append (or encounters a prior insertion) +/// means we can guarantee the insertion/deduplication to be idempotent and insert exactly once per completed execution +module ECommerce.Domain.ExactlyOnceIngester + +open FSharp.UMX // % + +type IngestResult<'req, 'res> = { accepted : 'res[]; closed : bool; residual : 'req[] } + +module Internal = + + let unknown<[]'m> = UMX.tag -1 + let next<[]'m> (value : int<'m>) = UMX.tag<'m>(UMX.untag value + 1) + +/// Ensures any given item is only added to the series exactly once by virtue of the following protocol: +/// 1. Caller obtains an origin epoch via ActiveIngestionEpochId, storing that alongside the source item +/// 2. Caller deterministically obtains that origin epoch to supply to Ingest/TryIngest such that retries can be idempotent +type Service<[]'id, 'req, 'res, 'outcome> internal + ( log : Serilog.ILogger, + readActiveEpoch : unit -> Async>, + markActiveEpoch : int<'id> -> Async, + ingest : int<'id> * 'req [] -> Async>, + mapResults : 'res [] -> 'outcome seq, + linger) = + + let uninitializedSentinel : int = %Internal.unknown + let mutable currentEpochId_ = uninitializedSentinel + let currentEpochId () = if currentEpochId_ <> uninitializedSentinel then Some %currentEpochId_ else None + + let tryIngest (reqs : (int<'id> * 'req)[][]) = + let rec aux ingestedItems items = async { + let epochId = items |> Array.minBy fst |> fst + let epochItems, futureEpochItems = items |> Array.partition (fun (e, _ : 'req) -> e = epochId) + let! res = ingest (epochId, Array.map snd epochItems) + let ingestedItemIds = Array.append ingestedItems res.accepted + let logLevel = + if res.residual.Length <> 0 || futureEpochItems.Length <> 0 || Array.isEmpty res.accepted then Serilog.Events.LogEventLevel.Information + else Serilog.Events.LogEventLevel.Debug + log.Write(logLevel, "Added {count}/{total} items to {epochId} Residual {residual} Future {future}", + res.accepted.Length, epochItems.Length, epochId, res.residual.Length, futureEpochItems.Length) + let nextEpochId = Internal.next epochId + let pushedToNextEpoch = res.residual |> Array.map (fun x -> nextEpochId, x) + match Array.append pushedToNextEpoch futureEpochItems with + | [||] -> + // Any writer noticing we've moved to a new Epoch shares the burden of marking it active in the Series + let newActiveEpochId = if res.closed then nextEpochId else epochId + if currentEpochId_ < %newActiveEpochId then + log.Information("Marking {epochId} active", newActiveEpochId) + do! markActiveEpoch newActiveEpochId + System.Threading.Interlocked.CompareExchange(¤tEpochId_, %newActiveEpochId, currentEpochId_) |> ignore + return ingestedItemIds + | remaining -> return! aux ingestedItemIds remaining } + aux [||] (Array.concat reqs) + + /// In the overall processing using an Ingester, we frequently have a Scheduler running N streams concurrently + /// If each thread works in isolation, they'll conflict with each other as they feed the Items into the batch in epochs.Ingest + /// Instead, we enable concurrent requests to coalesce by having requests converge in this AsyncBatchingGate + /// This has the following critical effects: + /// - Traffic to CosmosDB is naturally constrained to a single flight in progress + /// (BatchingGate does not release next batch for execution until current has succeeded or throws) + /// - RU consumption for writing to the batch is optimized (1 write inserting 1 event document vs N writers writing N) + /// - Peak throughput is more consistent as latency is not impacted by the combination of having to: + /// a) back-off, re-read and retry if there's a concurrent write Optimistic Concurrency Check failure when writing the stream + /// b) enter a prolonged period of retries if multiple concurrent writes trigger rate limiting and 429s from CosmosDB + /// c) readers will less frequently encounter sustained 429s on the batch + let batchedIngest = Equinox.Core.Batching.Batcher(tryIngest, linger) + + /// Run the requests over a chain of epochs. + /// Returns the subset that actually got handled this time around (exclusive of items that did not trigger events per idempotency rules). + member _.IngestMany(originEpoch, reqs) : Async<'outcome seq> = async { + if Array.isEmpty reqs then return Seq.empty else + + let! results = batchedIngest.Execute [| for x in reqs -> originEpoch, x |] + return results |> mapResults + } + + /// Exposes the current high water mark epoch - i.e. the tip epoch to which appends are presently being applied. + /// The fact that any Ingest call for a given item (or set of items) always commences from the same origin is key to exactly once insertion guarantee. + /// Caller should first store this alongside the item in order to deterministically be able to start from the same origin in idempotent retry cases. + /// Uses cached values as epoch transitions are rare, and caller needs to deal with the inherent race condition in any case + member _.ActiveIngestionEpochId() : Async> = + match currentEpochId () with + | Some currentEpochId -> async { return currentEpochId } + | None -> readActiveEpoch() + +let create log linger (readIngestionEpoch, markIngestionEpoch) (apply, mapResult) = + Service<'id, 'req, 'res, 'outcome>(log, readIngestionEpoch, markIngestionEpoch, apply, mapResult, linger = linger) diff --git a/Sample/ECommerce.Equinox/ECommerce.Domain/RandomProductPriceCalculator.fs b/Sample/ECommerce.Equinox/ECommerce.Domain/RandomProductPriceCalculator.fs new file mode 100644 index 000000000..662842a88 --- /dev/null +++ b/Sample/ECommerce.Equinox/ECommerce.Domain/RandomProductPriceCalculator.fs @@ -0,0 +1,12 @@ +namespace ECommerce.Domain + +type RandomProductPriceCalculator() = + + let productPrices = System.Collections.Concurrent.ConcurrentDictionary() + + member _.Calculate(productId : ProductId) : Async = async { + let r = System.Random() + let calc _ = (r.NextDouble() |> decimal) * 100m + let price : decimal = productPrices.GetOrAdd(productId, calc) + return price + } diff --git a/Sample/ECommerce.Equinox/ECommerce.Domain/ShoppingCart.fs b/Sample/ECommerce.Equinox/ECommerce.Domain/ShoppingCart.fs new file mode 100644 index 000000000..4d815e3ee --- /dev/null +++ b/Sample/ECommerce.Equinox/ECommerce.Domain/ShoppingCart.fs @@ -0,0 +1,169 @@ +module ECommerce.Domain.ShoppingCart + +open Propulsion.Internal + +let [] CategoryName = "ShoppingCart" +let streamId = FsCodec.StreamId.gen CartId.toString +let private catId = CategoryId(CategoryName, streamId, FsCodec.StreamId.dec CartId.(|Parse|)) + +module Events = + + type Event = + | Initialized of {| clientId : ClientId |} + | ItemAdded of {| productId : ProductId; quantity : int; unitPrice : decimal |} + | ItemRemoved of {| productId : ProductId; (*; quantity : int;*) unitPrice : decimal |} + | Confirmed of {| confirmedAt : System.DateTimeOffset |} + | Registering of {| originEpoch : ConfirmedEpochId |} + interface TypeShape.UnionContract.IUnionContract + let codec = Store.Codec.gen + let codecJsonElement = Store.Codec.genJsonElement + +module Reactions = + + let [] (|For|_|) = catId.TryDecode + let dec = Streams.Codec.dec + let config = catId.StreamName, dec + let [] (|Decode|_|) = function + | struct (For id, _) & Streams.Decode dec events -> ValueSome struct (id, events) + | _ -> ValueNone + let chooseConfirmed = function + | Events.Confirmed _ -> ValueSome () + | _ -> ValueNone + let [] (|Confirmed|_|) events = Seq.rev events |> Seq.tryPickV chooseConfirmed + let chooseNotRegistering = function + | Events.Registering _ -> ValueNone + | _ -> ValueSome () + let [] (|StateChanged|_|) events = Seq.rev events |> Seq.tryPickV chooseNotRegistering + +module Fold = + + type Item = { productId : ProductId; quantity : int; unitPrice : decimal } + + type Status = Pending | Confirmed + type State = + { clientId : ClientId option + status : Status; items : Item array + confirmedAt : System.DateTimeOffset option + confirmedOriginEpoch : ConfirmedEpochId option } + let initial = { clientId = None; status = Status.Pending; items = Array.empty; confirmedAt = None; confirmedOriginEpoch = None } + let isClosed (s : State) = match s.status with Confirmed -> true | Pending -> false + module ItemList = + let keys (x : Item) = x.productId, x.unitPrice + let add (productId, price, quantity) (current : Item seq) = + let newItemKeys = productId, price + let mkItem (productId, price, quantity) = { productId = productId; quantity = quantity; unitPrice = price } + let mutable merged = false + [| for x in current do + if newItemKeys = keys x then + mkItem (productId, price, x.quantity + quantity) + merged <- true + else + x + if not merged then + mkItem (productId, price, quantity) |] + let remove (productId, price) (current : Item[]) = + current |> Array.where (fun x -> keys x <> (productId, price)) + let private evolve s = function + | Events.Initialized e -> { s with clientId = Some e.clientId } + | Events.ItemAdded e -> { s with items = s.items |> ItemList.add (e.productId, e.unitPrice, e.quantity) } + | Events.ItemRemoved e -> { s with items = s.items |> ItemList.remove (e.productId, e.unitPrice) } + | Events.Confirmed e -> { s with status = Confirmed; confirmedAt = Some e.confirmedAt } + | Events.Registering e -> { s with confirmedOriginEpoch = Some e.originEpoch } + let fold = Seq.fold evolve + +let decideInitialize clientId (s : Fold.State) = [| + if Option.isNone s.clientId then + Events.Initialized {| clientId = clientId |} |] + +let decideAdd calculatePrice productId quantity state = async { + match state with + | s when Fold.isClosed s -> return invalidOp $"Adding product item for cart in '%A{s.status}' status is not allowed." + | _ -> + let! price = calculatePrice (productId, quantity) + return (), [| Events.ItemAdded {| productId = productId; unitPrice = price; quantity = quantity |} |] } + +let decideRemove (productId, price) = function + | s when Fold.isClosed s -> invalidOp $"Removing product item for cart in '%A{s.status}' status is not allowed." + | _ -> + [| Events.ItemRemoved {| productId = productId; unitPrice = price |} |] + +let decideConfirm at = function + | s when Fold.isClosed s -> [||] + | _ -> [| Events.Confirmed {| confirmedAt = at |} |] + +module Details = + + type View = { (* id *) clientId : ClientId; status : Fold.Status; items : Item[] } + and Item = { productId : ProductId; unitPrice : decimal; quantity : int } + + let render = function + | ({ clientId = None } : Fold.State) -> None + | { clientId = Some clientId } as s -> + let items = [| for { productId = productId; quantity = q; unitPrice = p } in s.items -> + { productId = productId; unitPrice = p; quantity = q } |] + Some { clientId = clientId; status = s.status; items = items } + +let summarizeWithOriginEpoch getActiveEpochId state = async { + match state with + | s when not (Fold.isClosed s) -> return failwith "Unexpected" + | { confirmedOriginEpoch = Some originEpoch } as s -> + return (Details.render s |> Option.get, originEpoch), [||] + | { confirmedOriginEpoch = None } as s -> + let! originEpoch = getActiveEpochId () + return (Details.render s |> Option.get, originEpoch), [| Events.Registering {| originEpoch = originEpoch |} |] } + +type Service internal (resolve : CartId -> Equinox.Decider, calculatePrice : ProductId * int -> Async) = + + member _.Initialize(cartId, clientId) = + let decider = resolve cartId + decider.Transact(decideInitialize clientId) + + member _.Add(cartId, productId, quantity) = + let decider = resolve cartId + decider.Transact(decideAdd calculatePrice productId quantity) + + member _.Remove(cartId, productId, price) = + let decider = resolve cartId + decider.Transact(decideRemove (productId, price)) + + member _.Confirm(cartId, at) = + let decider = resolve cartId + decider.Transact(decideConfirm at) + + // NOTE doing this does not fulfil the CQRS principle to the letter + // However, its not unrealistic for this demo in that + // a) it means you can read your writes immediately + // b) it's not unreasonable in efficiency terms + // - on Cosmos, you pay only 1RU to read through the cache with the etag + // - on EventStoreDB you are reading forward from a cached stream and hence are typically doing a roundtrip that does not send any events + member _.Read(cartId) : Async = + let decider = resolve cartId + decider.Query(Details.render) + + /// Summarizes the contents of the cart + /// Decides the tranche from where the insertion into the PoolTranches is to commence + member _.SummarizeWithOriginEpoch(cartId, getActiveEpochId) : Async = + let decider = resolve cartId + decider.Transact(summarizeWithOriginEpoch getActiveEpochId) + + /// Render view (and emit version on which it was based) for Denormalizer to store + member _.SummarizeWithVersion(cartId) : Async = + let decider = resolve cartId + decider.QueryEx(fun c -> Details.render c.State, c.Version) + +module Config = + + // Adapts the external Pricing algorithm interface shape (see IProductPriceCalculator) to what's required by `type Service` + let calculatePrice (pricer : ProductId -> Async) (productId, _quantity) : Async = + pricer productId + + let private (|Category|) = function + | Store.Config.Memory store -> Store.Memory.create CategoryName Events.codec Fold.initial Fold.fold store + | Store.Config.Cosmos (context, cache) -> Store.Cosmos.createUnoptimized CategoryName Events.codecJsonElement Fold.initial Fold.fold (context, cache) + | Store.Config.Dynamo (context, cache) -> Store.Dynamo.createUnoptimized CategoryName Events.codec Fold.initial Fold.fold (context, cache) + | Store.Config.Esdb (context, cache) -> Store.Esdb.createUnoptimized CategoryName Events.codec Fold.initial Fold.fold (context, cache) + | Store.Config.Sss (context, cache) -> Store.Sss.createUnoptimized CategoryName Events.codec Fold.initial Fold.fold (context, cache) + let create_ pricer (Category cat) = Service(streamId >> Store.createDecider cat, calculatePrice pricer) + let create = + let defaultCalculator = RandomProductPriceCalculator() + create_ defaultCalculator.Calculate diff --git a/Sample/ECommerce.Equinox/ECommerce.Domain/ShoppingCartSummary.fs b/Sample/ECommerce.Equinox/ECommerce.Domain/ShoppingCartSummary.fs new file mode 100644 index 000000000..4ee02d6db --- /dev/null +++ b/Sample/ECommerce.Equinox/ECommerce.Domain/ShoppingCartSummary.fs @@ -0,0 +1,71 @@ +module ECommerce.Domain.ShoppingCartSummary + +let [] CategoryName = "ShoppingCartSummary" + +let streamId = FsCodec.StreamId.gen CartId.toString + +module Events = + + type Ingested = { version : int64; value : Summary } + and Summary = { items : Item[]; status : Status; clientId : ClientId } + and Item = { productId : ProductId; unitPrice : decimal; quantity : int } + and Status = Pending | Confirmed + type Event = + | Ingested of Ingested + interface TypeShape.UnionContract.IUnionContract + let codec = Store.Codec.gen + let codecJsonElement = Store.Codec.genJsonElement + +module Fold = + + type State = Events.Ingested option + let initial = None + let private evolve _s = function + | Events.Ingested e -> Some e + let fold = Seq.fold evolve + let toSnapshot (s : State) = s.Value |> Events.Ingested + +module Details = + + type View = { (* id *) clientId : ClientId; status : Events.Status; items : Item[] } + and Item = { productId : ProductId; unitPrice : decimal; quantity : int } + + let render : Fold.State -> View option = function + | None -> None + | Some { value = v } -> + let items = [| for { productId = productId; quantity = q; unitPrice = p } in v.items -> + { productId = productId; unitPrice = p; quantity = q } |] + Some { clientId = v.clientId; status = v.status; items = items } + +module Ingest = + + let summarizeShoppingCartView (view : ShoppingCart.Details.View) : Events.Summary = + let mapStatus = function + | ShoppingCart.Fold.Pending -> Events.Pending + | ShoppingCart.Fold.Confirmed -> Events.Confirmed + { clientId = view.clientId; status = mapStatus view.status + items = [| for i in view.items -> { productId = i.productId; unitPrice = i.unitPrice; quantity = i.quantity } |] } + + let decide (version : int64, value : Events.Summary): Fold.State -> bool * Events.Event[] = function + | Some { version = v } when v >= version -> false, [||] + | None -> false, [||] + | _ -> true, [| Events.Ingested { version = version; value = value } |] + +type Service internal (resolve : CartId -> Equinox.Decider) = + + member _.Read(cartId) : Async = + let decider = resolve cartId + decider.Query(Details.render) + + member _.TryIngest(cartId, version, value) : Async = + let decider = resolve cartId + decider.Transact(Ingest.decide (version, Ingest.summarizeShoppingCartView value)) + +module Config = + + let private (|Category|) = function + | Store.Config.Memory store -> Store.Memory.create CategoryName Events.codec Fold.initial Fold.fold store + | Store.Config.Cosmos (context, cache) -> Store.Cosmos.createRollingState CategoryName Events.codecJsonElement Fold.initial Fold.fold Fold.toSnapshot (context, cache) + | Store.Config.Dynamo (context, cache) -> Store.Dynamo.createRollingState CategoryName Events.codec Fold.initial Fold.fold Fold.toSnapshot (context, cache) + | Store.Config.Esdb _ | Store.Config.Sss _ -> failwith "Not implemented: For EventStore/Sss its suggested to do a cached read from the write side" + let create (Category cat) = Service(streamId >> Store.createDecider cat) diff --git a/Sample/ECommerce.Equinox/ECommerce.Domain/Store.fs b/Sample/ECommerce.Equinox/ECommerce.Domain/Store.fs new file mode 100644 index 000000000..e1b0592cf --- /dev/null +++ b/Sample/ECommerce.Equinox/ECommerce.Domain/Store.fs @@ -0,0 +1,87 @@ +module Store + +module Metrics = + + let log = Serilog.Log.ForContext("isMetric", true) + +let createDecider category = Equinox.Decider.forStream Metrics.log category + +module Memory = + + let create name codec initial fold store : Equinox.Category<_, _, _> = + Equinox.MemoryStore.MemoryStoreCategory(store, name, FsCodec.Compression.EncodeUncompressed codec, fold, initial) + +module Codec = + + open FsCodec.SystemTextJson + let private defaultOptions = Options.Create(autoTypeSafeEnumToJsonString = true) + let gen<'t when 't :> TypeShape.UnionContract.IUnionContract> = + Codec.Create<'t>(options = defaultOptions) + let genJsonElement<'t when 't :> TypeShape.UnionContract.IUnionContract> = + CodecJsonElement.Create<'t>(options = defaultOptions) + +let private defaultCacheDuration = System.TimeSpan.FromMinutes 20. + +module Cosmos = + + let private createCached name codec initial fold accessStrategy (context, cache) = + let cacheStrategy = Equinox.CachingStrategy.SlidingWindow (cache, defaultCacheDuration) + Equinox.CosmosStore.CosmosStoreCategory(context, name, codec, fold, initial, accessStrategy, cacheStrategy) + + let createUnoptimized name codec initial fold (context, cache) = + let accessStrategy = Equinox.CosmosStore.AccessStrategy.Unoptimized + createCached name codec initial fold accessStrategy (context, cache) + + let createSnapshotted name codec initial fold (isOrigin, toSnapshot) (context, cache) = + let accessStrategy = Equinox.CosmosStore.AccessStrategy.Snapshot (isOrigin, toSnapshot) + createCached name codec initial fold accessStrategy (context, cache) + + let createRollingState name codec initial fold toSnapshot (context, cache) = + let accessStrategy = Equinox.CosmosStore.AccessStrategy.RollingState toSnapshot + createCached name codec initial fold accessStrategy (context, cache) + +module Dynamo = + + let private createCached name codec initial fold accessStrategy (context, cache) = + let cacheStrategy = Equinox.CachingStrategy.SlidingWindow (cache, defaultCacheDuration) + Equinox.DynamoStore.DynamoStoreCategory(context, name, FsCodec.Compression.EncodeTryCompress codec, fold, initial, accessStrategy, cacheStrategy) + + let createUnoptimized name codec initial fold (context, cache) = + let accessStrategy = Equinox.DynamoStore.AccessStrategy.Unoptimized + createCached name codec initial fold accessStrategy (context, cache) + + let createSnapshotted name codec initial fold (isOrigin, toSnapshot) (context, cache) = + let accessStrategy = Equinox.DynamoStore.AccessStrategy.Snapshot (isOrigin, toSnapshot) + createCached name codec initial fold accessStrategy (context, cache) + + let createRollingState name codec initial fold toSnapshot (context, cache) = + let accessStrategy = Equinox.DynamoStore.AccessStrategy.RollingState toSnapshot + createCached name codec initial fold accessStrategy (context, cache) + +module Esdb = + + let private createCached name codec initial fold accessStrategy (context, cache) = + let cacheStrategy = Equinox.CachingStrategy.SlidingWindow (cache, defaultCacheDuration) + Equinox.EventStoreDb.EventStoreCategory(context, name, codec, fold, initial, accessStrategy, cacheStrategy) + let createUnoptimized name codec initial fold (context, cache) = + createCached name codec initial fold Equinox.EventStoreDb.AccessStrategy.Unoptimized (context, cache) + let createLatestKnownEvent name codec initial fold (context, cache) = + createCached name codec initial fold Equinox.EventStoreDb.AccessStrategy.LatestKnownEvent (context, cache) + +module Sss = + + let private createCached name codec initial fold accessStrategy (context, cache) = + let cacheStrategy = Equinox.CachingStrategy.SlidingWindow (cache, defaultCacheDuration) + Equinox.SqlStreamStore.SqlStreamStoreCategory(context, name, codec, fold, initial, accessStrategy, cacheStrategy) + let createUnoptimized name codec initial fold (context, cache) = + createCached name codec initial fold Equinox.SqlStreamStore.AccessStrategy.Unoptimized (context, cache) + let createLatestKnownEvent name codec initial fold (context, cache) = + createCached name codec initial fold Equinox.SqlStreamStore.AccessStrategy.LatestKnownEvent (context, cache) + +[] +type Config = + | Memory of Equinox.MemoryStore.VolatileStore)> + | Cosmos of Equinox.CosmosStore.CosmosStoreContext * Equinox.Cache + | Dynamo of Equinox.DynamoStore.DynamoStoreContext * Equinox.Cache + | Esdb of Equinox.EventStoreDb.EventStoreContext * Equinox.Cache + | Sss of Equinox.SqlStreamStore.SqlStreamStoreContext * Equinox.Cache diff --git a/Sample/ECommerce.Equinox/ECommerce.Domain/Streams.fs b/Sample/ECommerce.Equinox/ECommerce.Domain/Streams.fs new file mode 100644 index 000000000..95dbfe02c --- /dev/null +++ b/Sample/ECommerce.Equinox/ECommerce.Domain/Streams.fs @@ -0,0 +1,22 @@ +module Streams + +open Serilog + +module Codec = + + let dec<'E when 'E :> TypeShape.UnionContract.IUnionContract> : Propulsion.Sinks.Codec<'E> = + FsCodec.SystemTextJson.Codec.Create<'E>() // options = Options.Default + + let private renderBody (x: Propulsion.Sinks.EventBody) = System.Text.Encoding.UTF8.GetString(x.Span) + + // Uses the supplied codec to decode the supplied event record (iff at LogEventLevel.Debug, failures are logged, citing `stream` and `.Data`) + let internal tryDecode<'E> (codec: Propulsion.Sinks.Codec<'E>) (streamName: FsCodec.StreamName) event = + match codec.Decode event with + | ValueNone when Log.IsEnabled Serilog.Events.LogEventLevel.Debug -> + Log.ForContext("eventData", renderBody event.Data) + .Debug("Codec {type} Could not decode {eventType} in {stream}", codec.GetType().FullName, event.EventType, streamName) + ValueNone + | x -> x + +let (|Decode|) codec struct (stream, events: Propulsion.Sinks.Event[]): 'E[] = + events |> Propulsion.Internal.Array.chooseV (Codec.tryDecode codec stream) diff --git a/Sample/ECommerce.Equinox/ECommerce.Domain/Types.fs b/Sample/ECommerce.Equinox/ECommerce.Domain/Types.fs new file mode 100644 index 000000000..394cbcb0f --- /dev/null +++ b/Sample/ECommerce.Equinox/ECommerce.Domain/Types.fs @@ -0,0 +1,78 @@ +namespace ECommerce.Domain + +open FSharp.UMX +open System + +module Guid = + + let inline toStringN (x : Guid) = x.ToString "N" + +type ProductId = Guid +and [] productId +module ProductId = + let toString (x : ProductId) : string = (UMX.untag x).ToString("N") + let parse (value : Guid) : ProductId = %value + let (|Parse|) = parse + +type ClientId = Guid +and [] clientId +module ClientId = + let toString (x : ClientId) : string = (UMX.untag x).ToString("N") + let parse (value : Guid Nullable) : ClientId = + if not value.HasValue || value.Value = Guid.Empty then raise <| ArgumentOutOfRangeException(nameof value) + %value.Value + let (|Parse|) = parse + +type CartId = Guid +and [] cartId +module CartId = + let toString (x : CartId) : string = (UMX.untag x).ToString("N") + let (|Parse|) : string -> CartId = Guid.Parse >> UMX.tag + let parse (value : Guid Nullable) : CartId = + if not value.HasValue || value.Value = Guid.Empty then raise <| ArgumentOutOfRangeException(nameof value) + %value.Value + let (|ParseGuid|) = parse + let generate () : CartId = Guid.NewGuid() |> Nullable |> parse + +(* At present, there's only a single series of Confirmed carts; this extension point could be used + e.g. if one was to implement multi-tenancy, the tenantId would become the seriesId *) + +type [] confirmedSeriesId +type ConfirmedSeriesId = int +module ConfirmedSeriesId = + let wellKnownId = 0 + let toString (value : ConfirmedSeriesId) : string = string %value + +type [] confirmedEpochId +type ConfirmedEpochId = int +module ConfirmedEpochId = + let initial = 0 + let value (value : ConfirmedEpochId) : int = %value + let parse (value : int) : ConfirmedEpochId = %value + let next (value : ConfirmedEpochId) : ConfirmedEpochId = % (%value + 1) + let toString (value : ConfirmedEpochId) : string = string %value + +type [] confirmedCheckpoint +type ConfirmedCheckpoint = int64 +module ConfirmedCheckpoint = + + let initial : ConfirmedCheckpoint = %0L + let factor = 1_000_000L + + let ofEpochAndOffset (epoch : ConfirmedEpochId) offset : ConfirmedCheckpoint = + int64 (ConfirmedEpochId.value epoch) * factor + int64 offset |> UMX.tag + + let ofEpochContent (epoch : ConfirmedEpochId) isClosed count : ConfirmedCheckpoint = + let epoch, offset = + if isClosed then ConfirmedEpochId.next epoch, 0 + else epoch, count + ofEpochAndOffset epoch offset + + let toEpochAndOffset (value : ConfirmedCheckpoint) : ConfirmedEpochId * int = + let d, r = Math.DivRem(%value, factor) + (ConfirmedEpochId.parse (int d)), int r + +/// Handles symmetric generation and decoding of StreamNames composed of a series of elements via the FsCodec.StreamId helpers +type internal CategoryId<'elements>(name, gen: 'elements -> FsCodec.StreamId, dec: FsCodec.StreamId -> 'elements) = + member _.StreamName = gen >> FsCodec.StreamName.create name + member _.TryDecode = FsCodec.StreamName.tryFind name >> ValueOption.map dec diff --git a/Sample/ECommerce.Equinox/ECommerce.FeedConsumer/ApiClient.fs b/Sample/ECommerce.Equinox/ECommerce.FeedConsumer/ApiClient.fs new file mode 100644 index 000000000..ae4a956ce --- /dev/null +++ b/Sample/ECommerce.Equinox/ECommerce.FeedConsumer/ApiClient.fs @@ -0,0 +1,88 @@ +module ECommerce.FeedConsumer.ApiClient + +open FSharp.UMX +open System.Net.Http + +open ECommerce.FeedConsumer.Domain + +(* The feed presents a Tranche (series of epochs) per FC *) + +module TrancheId = + + let toFcId (x : Propulsion.Feed.TrancheId) : FcId = %x + let ofFcId (x : FcId) : Propulsion.Feed.TrancheId = %x + +type TicketsEpochId = int + and [] ticketsEpochId + +[] +type TicketsTranchesDto = { activeEpochs : TrancheReferenceDto[] } + and TrancheReferenceDto = { fc : FcId; epochId : TicketsEpochId } + +(* Each Tranche response includes a checkpoint, which can be presented to Poll in order to resume consumption *) + +type TicketsCheckpoint = int64 + and [] ticketsCheckpoint +module TicketsCheckpoint = + let ofPosition (x : Propulsion.Feed.Position) : TicketsCheckpoint = %x + let toPosition (x : TicketsCheckpoint) : Propulsion.Feed.Position = %x + let toStreamIndex (x : TicketsCheckpoint) : int64 = %x + +type ItemDto = { id : TicketId; payload : string } +type SliceDto = { closed : bool; tickets : ItemDto[]; position : TicketsCheckpoint; checkpoint : TicketsCheckpoint } + +type Session(client: HttpClient) = + + member _.Send(req : HttpRequestMessage) : Async = + client.Send2(req) + +type TicketsClient(session: Session) = + + let basePath = "api/tickets" + + member _.ActiveFcs() : Async = async { + let request = HttpReq.get () |> HttpReq.withPath basePath + let! response = session.Send request + let! body = response |> HttpRes.deserializeOkStj + return [| for f in body.activeEpochs -> f.fc |] + } + + member _.ReadPage(fc : FcId, index : int) : Async = async { + let request = HttpReq.post () |> HttpReq.withPathf "%s/%O/%d" basePath fc index + let! response = session.Send request + return! response |> HttpRes.deserializeOkStj + } + + member _.Poll(fc : FcId, checkpoint: TicketsCheckpoint) : Async = async { + let request = HttpReq.create () |> HttpReq.withPathf "%s/%O/slice/%O" basePath fc checkpoint + let! response = session.Send request + return! response |> HttpRes.deserializeOkStj + } + +type Session with + + member session.Tickets = TicketsClient session + +type TicketsFeed(baseUri) = + + let client = new HttpClient(BaseAddress = baseUri) + let tickets = Session(client).Tickets + + let batch pg items : Propulsion.Feed.Page = + { checkpoint = TicketsCheckpoint.toPosition pg.checkpoint; items = items; isTail = not pg.closed } + + // TODO add retries - consumer loop will abort if this throws + member _.Poll(trancheId, pos) = async { + let checkpoint = TicketsCheckpoint.ofPosition pos + let! pg = tickets.Poll(TrancheId.toFcId trancheId, checkpoint) + let baseIndex = TicketsCheckpoint.toStreamIndex pg.position + let map (x : ItemDto) : Ingester.PipelineEvent.Item = { id = x.id; payload = x.payload } + let items = pg.tickets |> Array.mapi (fun i x -> Ingester.PipelineEvent.ofIndexAndItem (baseIndex + int64 i) (map x)) + return batch pg items + } + + // TODO add retries - consumer loop will not commence if this emits an exception + member _.ReadTranches() : Async = async { + let! activeFcs = tickets.ActiveFcs() + return [| for f in activeFcs -> TrancheId.ofFcId f |] + } diff --git a/Sample/ECommerce.Equinox/ECommerce.FeedConsumer/ECommerce.FeedConsumer.fsproj b/Sample/ECommerce.Equinox/ECommerce.FeedConsumer/ECommerce.FeedConsumer.fsproj new file mode 100644 index 000000000..d3587691f --- /dev/null +++ b/Sample/ECommerce.Equinox/ECommerce.FeedConsumer/ECommerce.FeedConsumer.fsproj @@ -0,0 +1,22 @@ + + + + Exe + net6.0 + 5 + + + + + + + + + + + + + + + + diff --git a/Sample/ECommerce.Equinox/ECommerce.FeedConsumer/Infrastructure.fs b/Sample/ECommerce.Equinox/ECommerce.FeedConsumer/Infrastructure.fs new file mode 100644 index 000000000..384c5f0de --- /dev/null +++ b/Sample/ECommerce.Equinox/ECommerce.FeedConsumer/Infrastructure.fs @@ -0,0 +1,183 @@ +[] +module ECommerce.FeedConsumer.Infrastructure + +type Async with + /// Re-raise an exception so that the current stacktrace is preserved + static member Raise(e : #exn) : Async<'T> = Async.FromContinuations (fun (_,ec,_) -> ec e) + +open System.Text + +type StringBuilder with + member sb.Appendf fmt = Printf.ksprintf (ignore << sb.Append) fmt + member sb.Appendfn fmt = Printf.ksprintf (ignore << sb.AppendLine) fmt + + static member inline Build(builder : StringBuilder -> unit) = + let instance = StringBuilder() // TOCONSIDER PooledStringBuilder.GetInstance() + builder instance + instance.ToString() + +open System +open System.Net +open System.Net.Http +open System.Runtime.Serialization + +/// Operations on System.Net.HttpRequestMessage +module HttpReq = + + /// Creates an HTTP GET request. + let inline create () = new HttpRequestMessage() + + /// Assigns a method to an HTTP request. + let inline withMethod (m : HttpMethod) (req : HttpRequestMessage) = + req.Method <- m + req + + /// Creates an HTTP GET request. + let inline get () = create () + + /// Creates an HTTP POST request. + let inline post () = create () |> withMethod HttpMethod.Post + + /// Assigns a path to an HTTP request. + let inline withUri (u : Uri) (req : HttpRequestMessage) = + req.RequestUri <- u + req + + /// Assigns a path to an HTTP request. + let inline withPath (p : string) (req : HttpRequestMessage) = + req |> withUri (Uri(p, UriKind.Relative)) + + /// Assigns a path to a Http request using printf-like formatting. + let inline withPathf fmt = + Printf.ksprintf withPath fmt + +type HttpContent with + member c.ReadAsString() = async { + match c with + | null -> return null + | c -> return! c.ReadAsStringAsync() |> Async.AwaitTask + } + + // only intended for logging under control of InvalidHttpResponseException, hence the esoteric name + member internal c.ReadAsStringDiapered() = async { + try return! c.ReadAsString() + with :? ObjectDisposedException -> return "" + } + +type HttpClient with + /// + /// Drop-in replacement for HttpClient.SendAsync which addresses known timeout issues + /// + /// HttpRequestMessage to be submitted. + member client.Send2(msg : HttpRequestMessage) = async { + let! ct = Async.CancellationToken + try return! client.SendAsync(msg, ct) |> Async.AwaitTask + // address https://github.com/dotnet/corefx/issues/20296 + with :? System.Threading.Tasks.TaskCanceledException -> + let message = + match client.BaseAddress with + | null -> "HTTP request timeout" + | baseAddr -> sprintf "HTTP request timeout [%O]" baseAddr + + return! Async.Raise(TimeoutException message) + } + +/// Exception indicating an unexpected response received by an Http Client +type InvalidHttpResponseException = + inherit Exception + + // TODO: include headers + val private userMessage : string + val private requestMethod : string + val RequestUri : Uri + val RequestBody : string + val StatusCode : HttpStatusCode + val ReasonPhrase : string + val ResponseBody : string + + member e.RequestMethod = HttpMethod(e.requestMethod) + + private new (userMessage : string, requestMethod : HttpMethod, requestUri : Uri, requestBody : string, + statusCode : HttpStatusCode, reasonPhrase : string, responseBody : string, + ?innerException : exn) = + { + inherit Exception(message = null, innerException = defaultArg innerException null) ; userMessage = userMessage ; + requestMethod = string requestMethod ; RequestUri = requestUri ; RequestBody = requestBody ; + StatusCode = statusCode ; ReasonPhrase = reasonPhrase ; ResponseBody = responseBody + } + + override e.Message = + StringBuilder.Build(fun sb -> + sb.Appendfn "%s %O RequestUri=%O HttpStatusCode=%O" e.userMessage e.RequestMethod e.RequestUri e.StatusCode + let getBodyString str = if String.IsNullOrWhiteSpace str then "" else str + sb.Appendfn "RequestBody=%s" (getBodyString e.RequestBody) + sb.Appendfn "ResponseBody=%s" (getBodyString e.ResponseBody)) + + interface ISerializable with + member e.GetObjectData(si : SerializationInfo, sc : StreamingContext) = + let add name (value:obj) = si.AddValue(name, value) + base.GetObjectData(si, sc) ; add "userMessage" e.userMessage ; + add "requestUri" e.RequestUri ; add "requestMethod" e.requestMethod ; add "requestBody" e.RequestBody + add "statusCode" e.StatusCode ; add "reasonPhrase" e.ReasonPhrase ; add "responseBody" e.ResponseBody + + new (si : SerializationInfo, sc : StreamingContext) = + let get name = si.GetValue(name, typeof<'a>) :?> 'a + { + inherit Exception(si, sc) ; userMessage = get "userMessage" ; + RequestUri = get "requestUri" ; requestMethod = get "requestMethod" ; RequestBody = get "requestBody" ; + StatusCode = get "statusCode" ; ReasonPhrase = get "reasonPhrase" ; ResponseBody = get "responseBody" + } + + static member Create(userMessage : string, response : HttpResponseMessage, ?innerException : exn) = async { + let request = response.RequestMessage + let! responseBodyC = response.Content.ReadAsStringDiapered() |> Async.StartChild + let! requestBody = request.Content.ReadAsStringDiapered() + let! responseBody = responseBodyC + return InvalidHttpResponseException( + userMessage, request.Method, request.RequestUri, requestBody, + response.StatusCode, response.ReasonPhrase, responseBody, + ?innerException = innerException) + } + + static member Create(response : HttpResponseMessage, ?innerException : exn) = + InvalidHttpResponseException.Create("HTTP request yielded unexpected response.", response, ?innerException = innerException) + +type HttpResponseMessage with + + /// Raises an InvalidHttpResponseException if the response status code does not match expected value. + member response.EnsureStatusCode(expectedStatusCode : HttpStatusCode) = async { + if response.StatusCode <> expectedStatusCode then + let! exn = InvalidHttpResponseException.Create("Http request yielded unanticipated HTTP Result.", response) + do raise exn + } + + /// Asynchronously deserializes the json response content using the supplied `deserializer`, without validating the `StatusCode` + /// The decoder routine to apply to the body content. Exceptions are wrapped in exceptions containing the offending content. + member response.InterpretContent<'Decoded>(deserializer : string -> 'Decoded) : Async<'Decoded> = async { + let! content = response.Content.ReadAsString() + try return deserializer content + with e -> + let! exn = InvalidHttpResponseException.Create("HTTP response could not be decoded.", response, e) + return raise exn + } + + /// Asynchronously deserializes the json response content using the supplied `deserializer`, validating the `StatusCode` is `expectedStatusCode` + /// check that status code matches supplied code or raise a InvalidHttpResponseException if it doesn't. + /// The decoder routine to apply to the body content. Exceptions are wrapped in exceptions containing the offending content. + member response.Interpret<'Decoded>(expectedStatusCode : HttpStatusCode, deserializer : string -> 'Decoded) : Async<'Decoded> = async { + do! response.EnsureStatusCode expectedStatusCode + return! response.InterpretContent deserializer + } + +module HttpRes = + +// let codec = ECommerce.Domain.Config.EventCodec.forUnion + let serdes = FsCodec.SystemTextJson.Serdes(FsCodec.SystemTextJson.Options.Create()) + + /// Deserialize body using default Json.Net profile - throw with content details if StatusCode is unexpected or decoding fails + let deserializeExpectedStj<'t> expectedStatusCode (res : HttpResponseMessage) = + res.Interpret(expectedStatusCode, serdes.Deserialize<'t>) + + /// Deserialize body using default Json.Net profile - throw with content details if StatusCode is not OK or decoding fails + let deserializeOkStj<'t> = + deserializeExpectedStj<'t> HttpStatusCode.OK diff --git a/Sample/ECommerce.Equinox/ECommerce.FeedConsumer/Ingester.fs b/Sample/ECommerce.Equinox/ECommerce.FeedConsumer/Ingester.fs new file mode 100644 index 000000000..f1ac96400 --- /dev/null +++ b/Sample/ECommerce.Equinox/ECommerce.FeedConsumer/Ingester.fs @@ -0,0 +1,63 @@ +module ECommerce.FeedConsumer.Ingester + +open ECommerce.FeedConsumer.Domain +open System + +type Outcome = { added : int; notReady : int; dups : int } + +/// Gathers stats based on the outcome of each Span processed for periodic emission +type Stats(log, statsInterval, stateInterval, logExternalStats) = + inherit Propulsion.Streams.Stats(log, statsInterval, stateInterval) + + let mutable added, notReady, dups = 0, 0, 0 + + override _.HandleOk outcome = + added <- added + outcome.added + notReady <- notReady + outcome.notReady + dups <- dups + outcome.dups + + override _.HandleExn(log, exn) = + log.Information(exn, "Unhandled") + + override _.DumpStats() = + if added <> 0 || notReady <> 0 || dups <> 0 then + log.Information("👉Added {added} Not Yet Shipped {notReady} Duplicates {dups}", added, notReady, dups) + added <- 0; notReady <- 0; dups <- 0 + base.DumpStats() + logExternalStats log + +module PipelineEvent = + + type Item = { id : TicketId; payload : string } + let ofIndexAndItem index (item : Item) = + FsCodec.Core.TimelineEvent.Create( + index, + "eventType", + Unchecked.defaultof<_>, + context = item) + let (|ItemsForFc|_|) = function + | FsCodec.StreamName.Split (_, FsCodec.StreamId.Parse 2 [|_ ; FcId.Parse fc|]), (s : Propulsion.Sinks.Event[]) -> + Some (fc, s |> Seq.map (fun e -> Unchecked.unbox e.Context)) + | _ -> None + +let handle maxDop stream span: Async = async { + match stream, span with + | PipelineEvent.ItemsForFc (_fc, items) -> + // Take chunks of max 1000 in order to make handler latency be less 'lumpy' + // What makes sense in terms of a good chunking size will vary depending on the workload in question + let ticketIds = seq { for x in items -> x.id } |> Seq.truncate 1000 |> Seq.toArray + let maybeAccept = Seq.distinct ticketIds |> Seq.mapi (fun i _x -> async { + do! Async.Sleep(TimeSpan.FromSeconds 1.) + return if i % 3 = 1 then Some 42 else None + }) + let! results = Async.Parallel(maybeAccept, maxDegreeOfParallelism=maxDop) + let ready = results |> Array.choose id + let maybeAdd = ready |> Seq.mapi (fun i _x -> async { + do! Async.Sleep(TimeSpan.FromSeconds 1.) + return if i % 2 = 1 then Some 42 else None + }) + let! added = Async.Parallel(maybeAdd, maxDegreeOfParallelism=maxDop) + let outcome = { added = Seq.length added; notReady = results.Length - ready.Length; dups = results.Length - ticketIds.Length } + return Propulsion.Sinks.StreamResult.PartiallyProcessed ticketIds.Length, outcome + | x -> return failwithf "Unexpected stream %O" x +} diff --git a/Sample/ECommerce.Equinox/ECommerce.FeedConsumer/Program.fs b/Sample/ECommerce.Equinox/ECommerce.FeedConsumer/Program.fs new file mode 100644 index 000000000..3e5d073ed --- /dev/null +++ b/Sample/ECommerce.Equinox/ECommerce.FeedConsumer/Program.fs @@ -0,0 +1,136 @@ +module ECommerce.FeedConsumer.Program + +open Serilog +open System + +type Configuration(tryGet) = + inherit Args.Configuration(tryGet) + + let get key = match tryGet key with Some value -> value | None -> failwith $"Missing Argument/Environment Variable %s{key}" + member _.BaseUri = get "API_BASE_URI" + member _.Group = get "API_CONSUMER_GROUP" + +let [] AppName = "FeedConsumer" + +module Args = + + open Argu + + [] + type Parameters = + | [] Verbose + | [] PrometheusPort of int + + | [] Group of string + | [] BaseUri of string + + | [] MaxReadAhead of int + | [] FcsDop of int + | [] TicketsDop of int + + | [] Cosmos of ParseResults + | [] Dynamo of ParseResults + | [] Esdb of ParseResults + | [] Sss of ParseResults + interface IArgParserTemplate with + member a.Usage = a |> function + | Verbose -> "request verbose logging." + | PrometheusPort _ -> "port from which to expose a Prometheus /metrics endpoint. Default: off (optional if environment variable PROMETHEUS_PORT specified)" + | Group _ -> "specify Api Consumer Group Id. (optional if environment variable API_CONSUMER_GROUP specified)" + | BaseUri _ -> "specify Api endpoint. (optional if environment variable API_BASE_URI specified)" + | MaxReadAhead _ -> "maximum number of batches to let processing get ahead of completion. Default: 8." + | FcsDop _ -> "maximum number of FCs to process in parallel. Default: 4" + | TicketsDop _ -> "maximum number of Tickets to process in parallel (per FC). Default: 4" + | Cosmos _ -> "specify CosmosDB input parameters" + | Dynamo _ -> "specify DynamoDB input parameters" + | Esdb _ -> "specify EventStore input parameters" + | Sss _ -> "specify SqlStreamStore input parameters" + + type Arguments(c : Configuration, a : ParseResults) = + member val Verbose = a.Contains Verbose + member val PrometheusPort = a.TryGetResult PrometheusPort |> Option.orElseWith (fun () -> c.PrometheusPort) + member val CacheSizeMb = 10 + member val SourceId = a.TryGetResult Group |> Option.defaultWith (fun () -> c.Group) |> Propulsion.Feed.SourceId.parse + member val BaseUri = a.TryGetResult BaseUri |> Option.defaultWith (fun () -> c.BaseUri) |> Uri + member val MaxReadAhead = a.GetResult(MaxReadAhead,8) + member val FcsDop = a.TryGetResult FcsDop |> Option.defaultValue 4 + member val TicketsDop = a.TryGetResult TicketsDop |> Option.defaultValue 4 + member val StatsInterval = TimeSpan.FromMinutes 1. + member val StateInterval = TimeSpan.FromMinutes 5. + member val CheckpointInterval = TimeSpan.FromHours 1. + member val TailSleepInterval = TimeSpan.FromSeconds 1. + member val ConsumerGroupName = "default" + member val StoreArgs : Args.StoreArgs = + match a.TryGetSubCommand() with + | Some (Parameters.Cosmos cosmos) -> Args.StoreArgs.Cosmos (Args.Cosmos.Arguments(c, cosmos)) + | Some (Parameters.Dynamo dynamo) -> Args.StoreArgs.Dynamo (Args.Dynamo.Arguments(c, dynamo)) + | Some (Parameters.Esdb es) -> Args.StoreArgs.Esdb (Args.Esdb.Arguments(c, es)) + | _ -> a.Raise "Must specify one of cosmos, dynamo or esdb for store" + member x.VerboseStore = Args.StoreArgs.verboseRequested x.StoreArgs + member x.DumpStoreMetrics = Args.StoreArgs.dumpMetrics x.StoreArgs + member x.Connect() : Store.Config * Propulsion.Feed.IFeedCheckpointStore = + let cache = Equinox.Cache(AppName, sizeMb = x.CacheSizeMb) + let createCheckpoints = Args.Checkpoints.create (x.ConsumerGroupName, x.CheckpointInterval) Store.Metrics.log + match x.StoreArgs with + | Args.StoreArgs.Cosmos a -> + let context = a.Connect() |> Async.RunSynchronously + let store = Store.Config.Cosmos (context, cache) + store, createCheckpoints (Args.Checkpoints.Config.Cosmos (context, cache)) + | Args.StoreArgs.Dynamo a -> + let context = a.Connect() + let store = Store.Config.Dynamo (context, cache) + store, createCheckpoints (Args.Checkpoints.Config.Dynamo (context, cache)) + | Args.StoreArgs.Esdb a -> + let context = a.Connect(Log.Logger, AppName, EventStore.Client.NodePreference.Leader) |> EventStoreContext.create + let store = Store.Config.Esdb (context, cache) + let checkpointStore = a.ConnectCheckpointStore(cache) + store, createCheckpoints checkpointStore + | Args.StoreArgs.Sss a -> + let context = a.Connect() |> SqlStreamStoreContext.create + let store = Store.Config.Sss (context, cache) + let checkpointStore = a.CreateCheckpointStoreSql(x.ConsumerGroupName) + store, checkpointStore + + /// Parse the commandline; can throw exceptions in response to missing arguments and/or `-h`/`--help` args + let parse tryGetConfigValue argv = + let programName = Reflection.Assembly.GetEntryAssembly().GetName().Name + let parser = ArgumentParser.Create(programName = programName) + Arguments(Configuration tryGetConfigValue, parser.ParseCommandLine argv) + +let build (args : Args.Arguments) = + let _store, checkpoints = args.Connect() // TODO wireup to use store in handler + + let log = Log.forGroup args.SourceId // needs to have a `group` tag for Propulsion.Streams Prometheus metrics + let sink = + let handle = Ingester.handle args.TicketsDop + let stats = Ingester.Stats(log, args.StatsInterval, args.StateInterval, logExternalStats = args.DumpStoreMetrics) + Propulsion.Sinks.Factory.StartConcurrent(log, args.MaxReadAhead, args.FcsDop, handle, stats) + let pumpSource = + let feed = ApiClient.TicketsFeed args.BaseUri + let source = + Propulsion.Feed.FeedSource( + log, args.StatsInterval, args.SourceId, args.TailSleepInterval, + checkpoints, sink) + source.Start(feed.ReadTranches, fun t p -> feed.Poll(t, p)) + sink, pumpSource + +open Propulsion.Internal // AwaitKeyboardInterruptAsTaskCanceledException + +let run args = async { + let sink, source = build args + use _ = args.PrometheusPort |> Option.map startMetricsServer |> Option.toObj + return! [| Async.AwaitKeyboardInterruptAsTaskCanceledException() + source.AwaitWithStopOnCancellation() + sink.AwaitWithStopOnCancellation() + |] |> Async.Parallel |> Async.Ignore } + +[] +let main argv = + try let args = Args.parse EnvVar.tryGet argv + try let metrics = Sinks.equinoxAndPropulsionFeedConsumerMetrics (Sinks.tags AppName) + Log.Logger <- LoggerConfiguration().Configure(args.Verbose).Sinks(metrics, args.VerboseStore).CreateLogger() + try run args |> Async.RunSynchronously; 0 + with e when not (e :? System.Threading.Tasks.TaskCanceledException) -> Log.Fatal(e, "Exiting"); 2 + finally Log.CloseAndFlush() + with:? Argu.ArguParseException as e -> eprintfn $"%s{e.Message}"; 1 + | e -> eprintfn $"Exception %s{e.Message}"; 1 diff --git a/Sample/ECommerce.Equinox/ECommerce.FeedConsumer/Types.fs b/Sample/ECommerce.Equinox/ECommerce.FeedConsumer/Types.fs new file mode 100644 index 000000000..1ad1ab9d4 --- /dev/null +++ b/Sample/ECommerce.Equinox/ECommerce.FeedConsumer/Types.fs @@ -0,0 +1,17 @@ +namespace ECommerce.FeedConsumer.Domain + +open FSharp.UMX // see https://github.com/fsprojects/FSharp.UMX - % operator and ability to apply units of measure to Guid+strings + +type [] fcId +type FcId = string +module FcId = + let toString (value : FcId) : string = %value + let parse (value : string) : FcId = let raw = value in % raw + let (|Parse|) = parse + +type [] ticketId +type TicketId = string +module TicketId = + let toString (value : TicketId) : string = %value + let parse (value : string) : TicketId = let raw = value in % raw + let (|Parse|) = parse diff --git a/Sample/ECommerce.Equinox/ECommerce.Infrastructure/Args.fs b/Sample/ECommerce.Equinox/ECommerce.Infrastructure/Args.fs new file mode 100644 index 000000000..e139546d7 --- /dev/null +++ b/Sample/ECommerce.Equinox/ECommerce.Infrastructure/Args.fs @@ -0,0 +1,288 @@ +/// Commandline arguments and/or secrets loading specifications +module Args + +open System + +let [] REGION = "EQUINOX_DYNAMO_REGION" +let [] SERVICE_URL = "EQUINOX_DYNAMO_SERVICE_URL" +let [] ACCESS_KEY = "EQUINOX_DYNAMO_ACCESS_KEY_ID" +let [] SECRET_KEY = "EQUINOX_DYNAMO_SECRET_ACCESS_KEY" +let [] TABLE = "EQUINOX_DYNAMO_TABLE" +let [] INDEX_TABLE = "EQUINOX_DYNAMO_TABLE_INDEX" + +type Configuration(tryGet : string -> string option) = + + let get key = match tryGet key with Some value -> value | None -> failwith $"Missing Argument/Environment Variable %s{key}" + member val tryGet = tryGet + + member _.CosmosConnection = get "EQUINOX_COSMOS_CONNECTION" + member _.CosmosDatabase = get "EQUINOX_COSMOS_DATABASE" + member _.CosmosContainer = get "EQUINOX_COSMOS_CONTAINER" + + member _.DynamoServiceUrl = get SERVICE_URL + member _.DynamoAccessKey = get ACCESS_KEY + member _.DynamoSecretKey = get SECRET_KEY + member _.DynamoTable = get TABLE + member _.DynamoRegion = tryGet REGION + + member _.EventStoreConnection = get "EQUINOX_ES_CONNECTION" + // member _.EventStoreCredentials = get "EQUINOX_ES_CREDENTIALS" + member _.MaybeEventStoreConnection = tryGet "EQUINOX_ES_CONNECTION" + member _.MaybeEventStoreCredentials = tryGet "EQUINOX_ES_CREDENTIALS" + + member _.SqlStreamStoreConnection = get "SQLSTREAMSTORE_CONNECTION" + member _.SqlStreamStoreCredentials = tryGet "SQLSTREAMSTORE_CREDENTIALS" + member _.SqlStreamStoreCredentialsCheckpoints = tryGet "SQLSTREAMSTORE_CREDENTIALS_CHECKPOINTS" + member _.SqlStreamStoreDatabase = get "SQLSTREAMSTORE_DATABASE" + member _.SqlStreamStoreContainer = get "SQLSTREAMSTORE_CONTAINER" + + member x.PrometheusPort = tryGet "PROMETHEUS_PORT" |> Option.map int + +// Type used to represent where checkpoints (for either the FeedConsumer position, or for a Reactor's Event Store subscription position) will be stored +// In a typical app you don't have anything like this as you'll simply use your primary Event Store (see) +module Checkpoints = + + [] + type Config = + | Cosmos of Equinox.CosmosStore.CosmosStoreContext * Equinox.Cache + | Dynamo of Equinox.DynamoStore.DynamoStoreContext * Equinox.Cache + (* Propulsion.EventStoreDb does not implement a native checkpoint storage mechanism, + perhaps port https://github.com/absolutejam/Propulsion.EventStoreDB ? + or fork/finish https://github.com/jet/dotnet-templates/pull/81 + alternately one could use a SQL Server DB via Propulsion.SqlStreamStore + + For now, we store the Checkpoints in one of the above stores as this sample uses one for the read models anyway *) + + let create (consumerGroup, checkpointInterval) storeLog: Config -> Propulsion.Feed.IFeedCheckpointStore = function + | Config.Cosmos (context, cache) -> + Propulsion.Feed.ReaderCheckpoint.CosmosStore.create storeLog (consumerGroup, checkpointInterval) (context, cache) + | Config.Dynamo (context, cache) -> + Propulsion.Feed.ReaderCheckpoint.DynamoStore.create storeLog (consumerGroup, checkpointInterval) (context, cache) + let createCheckpointStore (group, checkpointInterval, store) : Propulsion.Feed.IFeedCheckpointStore = + let checkpointStore = + match store with + | Store.Config.Cosmos (context, cache) -> Config.Cosmos (context, cache) + | Store.Config.Dynamo (context, cache) -> Config.Dynamo (context, cache) + | Store.Config.Esdb _ + | Store.Config.Memory _ + | Store.Config.Sss _ -> failwith "unexpected" + create (group, checkpointInterval) Store.Metrics.log checkpointStore + +open Argu + +module Cosmos = + + type [] Parameters = + | [] Verbose + | [] ConnectionMode of Microsoft.Azure.Cosmos.ConnectionMode + | [] Connection of string + | [] Database of string + | [] Container of string + | [] Timeout of float + | [] Retries of int + | [] RetriesWaitTime of float + interface IArgParserTemplate with + member p.Usage = p |> function + | Verbose _ -> "request verbose logging." + | 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)" + type Arguments(c : Configuration, p : ParseResults) = + let connection = p.GetResult(Connection, fun () -> c.CosmosConnection) + let discovery = Equinox.CosmosStore.Discovery.ConnectionString connection + let mode = p.TryGetResult ConnectionMode + let timeout = p.GetResult(Timeout, 5.) |> TimeSpan.FromSeconds + let retries = p.GetResult(Retries, 1) + let maxRetryWaitTime = p.GetResult(RetriesWaitTime, 5.) |> TimeSpan.FromSeconds + let connector = Equinox.CosmosStore.CosmosStoreConnector(discovery, timeout, retries, maxRetryWaitTime, ?mode = mode) + let database = p.GetResult(Database, fun () -> c.CosmosDatabase) + let container = p.GetResult(Container, fun () -> c.CosmosContainer) + member val Verbose = p.Contains Verbose + member _.Connect() = connector.Connect("Target", database, container) + +module Dynamo = + + type [] Parameters = + | [] Verbose + | [] RegionProfile of string + | [] ServiceUrl of string + | [] AccessKey of string + | [] SecretKey of string + | [] Table of string + | [] Retries of int + | [] RetriesTimeoutS of float + interface IArgParserTemplate with + member p.Usage = p |> function + | Verbose -> "Include low level Store logging." + | RegionProfile _ -> "specify an AWS Region (aka System Name, e.g. \"us-east-1\") to connect to using the implicit AWS SDK/tooling config and/or environment variables etc. Optional if:\n" + + "1) $" + REGION + " specified OR\n" + + "2) Explicit `ServiceUrl`/$" + SERVICE_URL + "+`AccessKey`/$" + ACCESS_KEY + "+`Secret Key`/$" + SECRET_KEY + " specified.\n" + + "See https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-envvars.html for details" + | ServiceUrl _ -> "specify a server endpoint for a Dynamo account. (Not applicable if `ServiceRegion`/$" + REGION + " specified; Optional if $" + SERVICE_URL + " specified)" + | AccessKey _ -> "specify an access key id for a Dynamo account. (Not applicable if `ServiceRegion`/$" + REGION + " specified; Optional if $" + ACCESS_KEY + " specified)" + | SecretKey _ -> "specify a secret access key for a Dynamo account. (Not applicable if `ServiceRegion`/$" + REGION + " specified; Optional if $" + SECRET_KEY + " specified)" + | Table _ -> "specify a table name for the primary store. (optional if $" + TABLE + " specified)" + | Retries _ -> "specify operation retries (default: 1)." + | RetriesTimeoutS _ -> "specify max wait-time including retries in seconds (default: 5)" + type Arguments(c : Configuration, p : ParseResults) = + let conn = match p.TryGetResult RegionProfile |> Option.orElseWith (fun () -> c.DynamoRegion) with + | Some systemName -> + Choice1Of2 systemName + | None -> + let serviceUrl = p.TryGetResult ServiceUrl |> Option.defaultWith (fun () -> c.DynamoServiceUrl) + let accessKey = p.TryGetResult AccessKey |> Option.defaultWith (fun () -> c.DynamoAccessKey) + let secretKey = p.TryGetResult SecretKey |> Option.defaultWith (fun () -> c.DynamoSecretKey) + Choice2Of2 (serviceUrl, accessKey, secretKey) + let retries = p.GetResult(Retries, 1) + let timeout = p.GetResult(RetriesTimeoutS, 5.) |> TimeSpan.FromSeconds + let connector = match conn with + | Choice1Of2 systemName -> + Equinox.DynamoStore.DynamoStoreConnector(systemName, timeout, retries) + | Choice2Of2 (serviceUrl, accessKey, secretKey) -> + Equinox.DynamoStore.DynamoStoreConnector(serviceUrl, accessKey, secretKey, timeout, retries) + let table = p.TryGetResult Table |> Option.defaultWith (fun () -> c.DynamoTable) + member val Verbose = p.Contains Verbose + member _.Connect() = connector.CreateClient().CreateContext("Main", table) + +module Esdb = + + [] + type Parameters = + | [] Verbose + | [] Connection of string + | [] Credentials of string + | [] Timeout of float + | [] Retries of int + + | [] Cosmos of ParseResults + | [] Dynamo of ParseResults + interface IArgParserTemplate with + member a.Usage = a |> function + | Verbose -> "Include low level Store logging." + | Connection _ -> "EventStore Connection String. (optional if environment variable EQUINOX_ES_CONNECTION specified)" + | Credentials _ -> "Credentials string for EventStore (used as part of connection string, but NOT logged). Default: use EQUINOX_ES_CREDENTIALS environment variable (or assume no credentials)" + | Timeout _ -> "specify operation timeout in seconds. Default: 20." + | Retries _ -> "specify operation retries. Default: 3." + + // Feed Consumer app needs somewhere to store checkpoints + // Here we align with the structure of the commandline parameters for the Reactor app and also require a Dynamo or Cosmos instance to be specified + | Cosmos _ -> "CosmosDB (Checkpoint/Target) Store parameters (Not applicable for Web app)." + | Dynamo _ -> "DynamoDB (Checkpoint/Target) Store parameters (Not applicable to Web app)." + + type Arguments(c : Configuration, a : ParseResults) = + let connectionStringLoggable = a.TryGetResult Connection |> Option.defaultWith (fun () -> c.EventStoreConnection) + let credentials = a.TryGetResult Credentials |> Option.orElseWith (fun () -> c.MaybeEventStoreCredentials) + let retries = a.GetResult(Retries, 3) + let timeout = a.GetResult(Timeout, 20.) |> TimeSpan.FromSeconds + member _.Verbose = a.Contains Verbose + + member x.Connect(log : Serilog.ILogger, appName, nodePreference) : Equinox.EventStoreDb.EventStoreConnection = + log.Information("EventStore {discovery}", connectionStringLoggable) + let discovery = match credentials with Some x -> String.Join(";", connectionStringLoggable, x) | None -> connectionStringLoggable + |> Equinox.EventStoreDb.Discovery.ConnectionString + let tags=["M", Environment.MachineName; "I", Guid.NewGuid() |> string] + Equinox.EventStoreDb.EventStoreConnector(timeout, retries, tags = tags) + .Establish(appName, discovery, Equinox.EventStoreDb.ConnectionStrategy.ClusterSingle nodePreference) + + member _.SecondaryStoreArgs : SecondaryStoreArgs = + match a.GetSubCommand() with + | Cosmos cosmos -> SecondaryStoreArgs.Cosmos (Cosmos.Arguments(c, cosmos)) + | Dynamo dynamo -> SecondaryStoreArgs.Dynamo (Dynamo.Arguments(c, dynamo)) + | _ -> a.Raise "Must specify `cosmos` or `dynamo` target store when source is `esdb`" + + member x.ConnectCheckpointStore(cache) = + match x.SecondaryStoreArgs with + | SecondaryStoreArgs.Cosmos a -> + let context = a.Connect() |> Async.RunSynchronously + Checkpoints.Config.Cosmos (context, cache) + | SecondaryStoreArgs.Dynamo a -> + let context = a.Connect() + Checkpoints.Config.Dynamo (context, cache) + + and [] + SecondaryStoreArgs = + | Cosmos of Cosmos.Arguments + | Dynamo of Dynamo.Arguments + +module Sss = + + // TOCONSIDER: add DB connectors other than MsSql + type [] Parameters = + | [] Connection of string + | [] Credentials of string + | [] Schema of string + | [] CheckpointsConnection of string + | [] CheckpointsCredentials of string + // | [] BatchSize of int + interface IArgParserTemplate with + member p.Usage = p |> function + | Connection _ -> "Connection string for SqlStreamStore db. Optional if SQLSTREAMSTORE_CONNECTION specified" + | Credentials _ -> "Credentials string for SqlStreamStore db (used as part of connection string, but NOT logged). Default: use SQLSTREAMSTORE_CREDENTIALS environment variable (or assume no credentials)" + | Schema _ -> "Database schema name" + | CheckpointsConnection _ ->"Connection string for Checkpoints sql db. Optional if SQLSTREAMSTORE_CONNECTION_CHECKPOINTS specified. Default: same as `Connection`" + | CheckpointsCredentials _ ->"Credentials string for Checkpoints sql db. (used as part of checkpoints connection string, but NOT logged). Default (when no `CheckpointsConnection`: use `Credentials. Default (when `CheckpointsConnection` specified): use SQLSTREAMSTORE_CREDENTIALS_CHECKPOINTS environment variable (or assume no credentials)" + // | BatchSize _ -> "Maximum events to request from feed. Default: 512" + + type Arguments(c : Configuration, p : ParseResults) = + // let batchSize = p.GetResult(BatchSize, 512) + let connection = p.TryGetResult Connection |> Option.defaultWith (fun () -> c.SqlStreamStoreConnection) + let credentials = p.TryGetResult Credentials |> Option.orElseWith (fun () -> c.SqlStreamStoreCredentials) |> Option.toObj + let schema = p.GetResult(Schema, null) + + member x.Connect() = + let conn, creds, schema, autoCreate = connection, credentials, schema, false + let sssConnectionString = String.Join(";", conn, creds) + Serilog.Log.Information("SqlStreamStore MsSql Connection {connectionString} Schema {schema} AutoCreate {autoCreate}", conn, schema, autoCreate) + let rawStore = Equinox.SqlStreamStore.MsSql.Connector(sssConnectionString, schema, autoCreate=autoCreate).Connect() |> Async.RunSynchronously + Equinox.SqlStreamStore.SqlStreamStoreConnection(rawStore) + member x.BuildCheckpointsConnectionString() = + let c, cs = + match p.TryGetResult CheckpointsConnection, p.TryGetResult CheckpointsCredentials with + | Some c, Some p -> c, String.Join(";", c, p) + | None, Some p -> let c = connection in c, String.Join(";", c, p) + | None, None -> let c = connection in c, String.Join(";", c, credentials) + | Some cc, None -> let p = c.SqlStreamStoreCredentialsCheckpoints |> Option.toObj + cc, String.Join(";", cc, p) + Serilog.Log.Information("Checkpoints MsSql Connection {connectionString}", c) + cs + member x.CreateCheckpointStoreSql(groupName) : Propulsion.Feed.IFeedCheckpointStore = + let connectionString = x.BuildCheckpointsConnectionString() + Propulsion.SqlStreamStore.ReaderCheckpoint.Service(connectionString, groupName) + +type [] + StoreArgs = + | Cosmos of Cosmos.Arguments + | Dynamo of Dynamo.Arguments + | Esdb of Esdb.Arguments + | Sss of Sss.Arguments + +module StoreArgs = + + let connectTarget targetStore cache = + match targetStore with + | StoreArgs.Cosmos a -> + let context = a.Connect() |> Async.RunSynchronously + Store.Config.Cosmos (context, cache) + | StoreArgs.Dynamo a -> + let context = a.Connect() + Store.Config.Dynamo (context, cache) + | StoreArgs.Esdb a -> + let context = a.Connect(Serilog.Log.Logger, "Main", EventStore.Client.NodePreference.Leader) |> EventStoreContext.create + Store.Config.Esdb (context, cache) + | StoreArgs.Sss a -> + let context = a.Connect() |> SqlStreamStoreContext.create + Store.Config.Sss (context, cache) + let verboseRequested = function + | StoreArgs.Cosmos a -> a.Verbose + | StoreArgs.Dynamo a -> a.Verbose + | StoreArgs.Esdb a -> a.Verbose + | StoreArgs.Sss a -> false + let dumpMetrics = function + | StoreArgs.Cosmos _ -> Equinox.CosmosStore.Core.Log.InternalMetrics.dump + | StoreArgs.Dynamo _ -> Equinox.DynamoStore.Core.Log.InternalMetrics.dump + | StoreArgs.Esdb _ -> Equinox.EventStoreDb.Log.InternalMetrics.dump + | StoreArgs.Sss _ -> Equinox.SqlStreamStore.Log.InternalMetrics.dump diff --git a/Sample/ECommerce.Equinox/ECommerce.Infrastructure/ECommerce.Infrastructure.fsproj b/Sample/ECommerce.Equinox/ECommerce.Infrastructure/ECommerce.Infrastructure.fsproj new file mode 100644 index 000000000..271393a4f --- /dev/null +++ b/Sample/ECommerce.Equinox/ECommerce.Infrastructure/ECommerce.Infrastructure.fsproj @@ -0,0 +1,35 @@ + + + + net6.0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Sample/ECommerce.Equinox/ECommerce.Infrastructure/Infrastructure.fs b/Sample/ECommerce.Equinox/ECommerce.Infrastructure/Infrastructure.fs new file mode 100644 index 000000000..da00aece9 --- /dev/null +++ b/Sample/ECommerce.Equinox/ECommerce.Infrastructure/Infrastructure.fs @@ -0,0 +1,173 @@ +[] +module Helpers + +open Serilog +open System + +module Log = + + let isStoreMetrics x = Serilog.Filters.Matching.WithProperty("isMetric").Invoke x + let forGroup group = Log.ForContext("group", group) + +module EnvVar = + + let tryGet varName : string option = Environment.GetEnvironmentVariable varName |> Option.ofObj + +type Equinox.CosmosStore.CosmosStoreContext with + + member x.LogConfiguration(role, databaseId: string, containerId: string) = + Log.Information("CosmosStore {role:l} {db}/{container} Tip maxEvents {maxEvents} maxSize {maxJsonLen} Query maxItems {queryMaxItems}", + role, databaseId, containerId, x.TipOptions.MaxEvents, x.TipOptions.MaxJsonLength, x.QueryOptions.MaxItems) + +type Equinox.CosmosStore.CosmosStoreClient with + + member x.CreateContext(role: string, databaseId, containerId, tipMaxEvents, ?queryMaxItems, ?tipMaxJsonLength, ?skipLog) = + let c = Equinox.CosmosStore.CosmosStoreContext(x, databaseId, containerId, tipMaxEvents, ?queryMaxItems = queryMaxItems, ?tipMaxJsonLength = tipMaxJsonLength) + if skipLog = Some true then () else c.LogConfiguration(role, databaseId, containerId) + c + +module CosmosStoreConnector = + + let private get (role: string) (client: Microsoft.Azure.Cosmos.CosmosClient) databaseId containerId = + Log.Information("CosmosDB {role} Database {database} Container {container}", role, databaseId, containerId) + client.GetDatabase(databaseId).GetContainer(containerId) + let getSource c = get "Source" c + let getLeases c = get "Leases" c + let getSourceAndLeases client databaseId containerId auxContainerId = + getSource client databaseId containerId, getLeases client databaseId auxContainerId + +type Equinox.CosmosStore.CosmosStoreConnector with + + member private x.LogConfiguration(role, databaseId: string, containers: string[]) = + let o = x.Options + let timeout, retries429, timeout429 = o.RequestTimeout, o.MaxRetryAttemptsOnRateLimitedRequests, o.MaxRetryWaitTimeOnRateLimitedRequests + Log.Information("CosmosDB {role} {mode} {endpointUri} {db} {containers} timeout {timeout}s Throttling retries {retries}, max wait {maxRetryWaitTime}s", + role, o.ConnectionMode, x.Endpoint, databaseId, containers, timeout.TotalSeconds, retries429, let t = timeout429.Value in t.TotalSeconds) + member private x.CreateAndInitialize(role, databaseId, containers) = + x.LogConfiguration(role, databaseId, containers) + x.CreateAndInitialize(databaseId, containers) + // member private x.Connect(role, databaseId, containers) = + // x.LogConfiguration(role, databaseId, containers) + // x.Connect(databaseId, containers) + member private x.ConnectContexts(role, databaseId, containerId, ?auxContainerId): Async<_ * Equinox.CosmosStore.CosmosStoreContext> = async { + let! cosmosClient = x.CreateAndInitialize(role, databaseId, [| yield containerId; yield! Option.toList auxContainerId |]) + let client = Equinox.CosmosStore.CosmosStoreClient(cosmosClient) + let contexts = client.CreateContext(role, databaseId, containerId, tipMaxEvents = 256, queryMaxItems = 100) + return cosmosClient, contexts } + /// Connect to the database (including verifying and warming up relevant containers), establish relevant CosmosStoreContexts required by Domain + member x.Connect(role, databaseId, containerId: string) = async { + let! _client, contexts = x.ConnectContexts(role, databaseId, containerId) + return contexts } + member x.ConnectWithFeed(databaseId, containerId, auxContainerId) = async { + let! client, context = x.ConnectContexts("Main", databaseId, containerId, auxContainerId) + let source, leases = CosmosStoreConnector.getSourceAndLeases client databaseId containerId auxContainerId + return context, source, leases } + +type Equinox.DynamoStore.DynamoStoreConnector with + + member x.LogConfiguration() = + Log.Information("DynamoStore {endpoint} Timeout {timeoutS}s Retries {retries}", + x.Endpoint, (let t = x.Timeout in t.TotalSeconds), x.Retries) + + member x.CreateClient() = + x.LogConfiguration() + x.CreateDynamoStoreClient() + +type Equinox.DynamoStore.DynamoStoreClient with + + member x.CreateContext(role, table, ?queryMaxItems, ?maxBytes, ?archiveTableName: string) = + let queryMaxItems = defaultArg queryMaxItems 100 + let c = Equinox.DynamoStore.DynamoStoreContext(x, table, queryMaxItems = queryMaxItems, ?maxBytes = maxBytes, ?archiveTableName = archiveTableName) + Log.Information("DynamoStore {role:l} Table {table} Archive {archive} Tip thresholds: {maxTipBytes}b {maxTipEvents}e Query paging {queryMaxItems} items", + role, table, Option.toObj archiveTableName, c.TipOptions.MaxBytes, Option.toNullable c.TipOptions.MaxEvents, c.QueryOptions.MaxItems) + c + +type Equinox.DynamoStore.DynamoStoreContext with + + member context.CreateCheckpointService(consumerGroupName, cache, log, ?checkpointInterval) = + let checkpointInterval = defaultArg checkpointInterval (TimeSpan.FromHours 1.) + Propulsion.Feed.ReaderCheckpoint.DynamoStore.create log (consumerGroupName, checkpointInterval) (context, cache) + +module EventStoreContext = + + let create (storeConnection : Equinox.EventStoreDb.EventStoreConnection) = + Equinox.EventStoreDb.EventStoreContext(storeConnection, batchSize = 200) + +module SqlStreamStoreContext = + + let create (storeConnection : Equinox.SqlStreamStore.SqlStreamStoreConnection) = + Equinox.SqlStreamStore.SqlStreamStoreContext(storeConnection, batchSize = 200) + +/// Equinox and Propulsion provide metrics as properties in log emissions +/// These helpers wire those to pass through virtual Log Sinks that expose them as Prometheus metrics. +module Sinks = + + let tags appName = ["app", appName] + + let equinoxMetricsOnly tags (l : LoggerConfiguration) = + l.WriteTo.Sink(Equinox.CosmosStore.Core.Log.InternalMetrics.Stats.LogSink()) + .WriteTo.Sink(Equinox.CosmosStore.Prometheus.LogSink(tags)) + .WriteTo.Sink(Equinox.DynamoStore.Core.Log.InternalMetrics.Stats.LogSink()) + .WriteTo.Sink(Equinox.DynamoStore.Prometheus.LogSink(tags)) + .WriteTo.Sink(Equinox.EventStoreDb.Log.InternalMetrics.Stats.LogSink()) + + let equinoxAndPropulsionMetrics tags (l : LoggerConfiguration) = + l |> equinoxMetricsOnly tags + |> _.WriteTo.Sink(Propulsion.Prometheus.LogSink(tags)) + + let equinoxAndPropulsionReactorMetrics tags (l : LoggerConfiguration) = + l |> equinoxAndPropulsionMetrics tags + |> _.WriteTo.Sink(Propulsion.CosmosStore.Prometheus.LogSink(tags)) + .WriteTo.Sink(Propulsion.Feed.Prometheus.LogSink(tags)) // Esdb and Dynamo indirectly provide metrics via Feed + + let equinoxAndPropulsionFeedConsumerMetrics tags (l : LoggerConfiguration) = + l |> equinoxAndPropulsionMetrics tags + |> _.WriteTo.Sink(Propulsion.Feed.Prometheus.LogSink(tags)) + + let console (configuration : LoggerConfiguration) = + let t = "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj} {NewLine}{Exception}" + configuration.WriteTo.Console(theme=Sinks.SystemConsole.Themes.AnsiConsoleTheme.Code, outputTemplate=t) + +[] +type Logging() = + + [] + static member Configure(configuration : LoggerConfiguration, ?verbose) = + configuration + .Destructure.FSharpTypes() + .Enrich.FromLogContext() + |> fun c -> if verbose = Some true then c.MinimumLevel.Debug() else c + + [] + static member private Sinks(configuration : LoggerConfiguration, configureMetricsSinks, configureConsoleSink, ?isMetric) = + let configure (a : Configuration.LoggerSinkConfiguration) : unit = + a.Logger(configureMetricsSinks >> ignore) |> ignore // unconditionally feed all log events to the metrics sinks + a.Logger(fun l -> // but filter what gets emitted to the console sink + let l = match isMetric with None -> l | Some predicate -> l.Filter.ByExcluding(Func predicate) + configureConsoleSink l |> ignore) + |> ignore + configuration.WriteTo.Async(bufferSize = 65536, blockWhenFull = true, configure = System.Action<_> configure) + + [] + static member Sinks(configuration : LoggerConfiguration, configureMetricsSinks, verboseStore) = + configuration.Sinks(configureMetricsSinks, Sinks.console, ?isMetric = if verboseStore then None else Some Log.isStoreMetrics) + +/// A typical app will likely have health checks etc, implying the wireup would be via `UseMetrics()` and thus not use this ugly code directly +let startMetricsServer port : IDisposable = + let metricsServer = new Prometheus.KestrelMetricServer(port = port) + let ms = metricsServer.Start() + Log.Information("Prometheus /metrics endpoint on port {port}", port) + { new IDisposable with member x.Dispose() = ms.Stop(); (metricsServer :> IDisposable).Dispose() } + +module Exception = + + let dump verboseStore (log : ILogger) (exn : exn) = + match exn with + | :? Microsoft.Azure.Cosmos.CosmosException as e + when (e.StatusCode = System.Net.HttpStatusCode.TooManyRequests + || e.StatusCode = System.Net.HttpStatusCode.ServiceUnavailable) + && not verboseStore -> () + | Equinox.DynamoStore.Exceptions.ProvisionedThroughputExceeded + | :? TimeoutException when not verboseStore -> () + | _ -> + log.Information(exn, "Unhandled") diff --git a/Sample/ECommerce.Equinox/ECommerce.Infrastructure/SourceConfig.fs b/Sample/ECommerce.Equinox/ECommerce.Infrastructure/SourceConfig.fs new file mode 100644 index 000000000..a8553efd2 --- /dev/null +++ b/Sample/ECommerce.Equinox/ECommerce.Infrastructure/SourceConfig.fs @@ -0,0 +1,114 @@ +namespace global + +open System +open System.Threading.Tasks + +[] +type SourceConfig = + | Memory of store : Equinox.MemoryStore.VolatileStore)> + | Cosmos of monitoredContainer: Microsoft.Azure.Cosmos.Container + * leasesContainer: Microsoft.Azure.Cosmos.Container + * checkpoints: CosmosFeedConfig + * tailSleepInterval: TimeSpan + * statsInterval: TimeSpan + | Dynamo of indexContext: Equinox.DynamoStore.DynamoStoreContext + * checkpoints: Propulsion.Feed.IFeedCheckpointStore + * loading: Propulsion.DynamoStore.EventLoadMode + * startFromTail: bool + * batchSizeCutoff: int + * tailSleepInterval: TimeSpan + * statsInterval: TimeSpan + | Esdb of client : EventStore.Client.EventStoreClient + * checkpoints : Propulsion.Feed.IFeedCheckpointStore + * hydrateBodies : bool + * startFromTail : bool + * batchSize : int + * tailSleepInterval : TimeSpan + * statsInterval : TimeSpan + | Sss of client : SqlStreamStore.IStreamStore + * checkpoints : Propulsion.Feed.IFeedCheckpointStore + * hydrateBodies : bool + * startFromTail : bool + * batchSize : int + * tailSleepInterval : TimeSpan + * statsInterval : TimeSpan +and [] CosmosFeedConfig = + | Ephemeral of processorName : string + | Persistent of processorName : string * startFromTail : bool * maxItems : int option * lagFrequency : TimeSpan +and [] DynamoLoadModeConfig = + | Hydrate of monitoredContext : Equinox.DynamoStore.DynamoStoreContext * hydrationConcurrency : int + +module SourceConfig = + module Memory = + open Propulsion.MemoryStore + let start log (sink: Propulsion.Sinks.SinkPipeline) (categories: string[]) + (store: Equinox.MemoryStore.VolatileStore<_>): Propulsion.Pipeline * (TimeSpan -> Task) = + let source = MemoryStoreSource(log, store, categories, sink) + source.Start(), fun _propagationDelay -> source.Monitor.AwaitCompletion(ignoreSubsequent = false) + module Cosmos = + open Propulsion.CosmosStore + let start log (sink: Propulsion.Sinks.SinkPipeline) categories + (monitoredContainer, leasesContainer, checkpointConfig, tailSleepInterval, statsInterval): Propulsion.Pipeline * (TimeSpan -> Task) = + let parseFeedDoc = EquinoxSystemTextJsonParser.ofCategories categories + let source = + match checkpointConfig with + | Ephemeral processorName -> + let withStartTime1sAgo (x: Microsoft.Azure.Cosmos.ChangeFeedProcessorBuilder) = + x.WithStartTime(let t = DateTime.UtcNow in t.AddSeconds -1.) + let lagFrequency = TimeSpan.FromMinutes 1. + CosmosStoreSource(log, statsInterval, monitoredContainer, leasesContainer, processorName, parseFeedDoc, sink, + startFromTail = true, customize = withStartTime1sAgo, tailSleepInterval = tailSleepInterval, + lagEstimationInterval = lagFrequency).Start() + | Persistent (processorName, startFromTail, maxItems, lagFrequency) -> + CosmosStoreSource(log, statsInterval, monitoredContainer, leasesContainer, processorName, parseFeedDoc, sink, + startFromTail = startFromTail, ?maxItems = maxItems, tailSleepInterval = tailSleepInterval, + lagEstimationInterval = lagFrequency).Start() + source, fun propagationDelay -> source.Monitor.AwaitCompletion(propagationDelay, ignoreSubsequent = false) + module Dynamo = + open Propulsion.DynamoStore + let create (log, storeLog) (sink: Propulsion.Sinks.SinkPipeline) categories + (indexContext, checkpoints, loadMode, startFromTail, batchSizeCutoff, tailSleepInterval, statsInterval) trancheIds = + DynamoStoreSource( + log, statsInterval, + indexContext, batchSizeCutoff, tailSleepInterval, + checkpoints, sink, loadMode, categories = categories, + startFromTail = startFromTail, storeLog = storeLog, ?trancheIds = trancheIds) + let start (log, storeLog) sink categories (indexContext, checkpoints, loadMode, startFromTail, batchSizeCutoff, tailSleepInterval, statsInterval) + : Propulsion.Pipeline * (TimeSpan -> Task) = + let source = create (log, storeLog) sink categories (indexContext, checkpoints, loadMode, startFromTail, batchSizeCutoff, tailSleepInterval, statsInterval) None + let source = source.Start() + source, fun propagationDelay -> source.Monitor.AwaitCompletion(propagationDelay, ignoreSubsequent = false) + module Esdb = + open Propulsion.EventStoreDb + let start log (sink: Propulsion.Sinks.SinkPipeline) categories + (client, checkpoints, withData, startFromTail, batchSize, tailSleepInterval, statsInterval): Propulsion.Pipeline * (TimeSpan -> Task) = + let source = + EventStoreSource( + log, statsInterval, + client, batchSize, tailSleepInterval, + checkpoints, sink, categories, withData = withData, startFromTail = startFromTail) + let source = source.Start() + source, fun propagationDelay -> source.Monitor.AwaitCompletion(propagationDelay, ignoreSubsequent = false) + module Sss = + open Propulsion.SqlStreamStore + let start log (sink: Propulsion.Sinks.SinkPipeline) categoryFilter + (client, checkpoints, withData, startFromTail, batchSize, tailSleepInterval, statsInterval) : Propulsion.Pipeline * (TimeSpan -> Task) = + let source = + SqlStreamStoreSource( + log, statsInterval, + client, batchSize, tailSleepInterval, + checkpoints, sink, categoryFilter, withData = withData, startFromTail = startFromTail) + let source = source.Start() + source, fun propagationDelay -> source.Monitor.AwaitCompletion(propagationDelay, ignoreSubsequent = false) + + let start (log, storeLog) sink categories: SourceConfig -> Propulsion.Pipeline * (TimeSpan -> Task) = function + | SourceConfig.Memory volatileStore -> + Memory.start log sink categories volatileStore + | SourceConfig.Cosmos (monitored, leases, checkpointConfig, tailSleepInterval, statsInterval) -> + Cosmos.start log sink categories (monitored, leases, checkpointConfig, tailSleepInterval, statsInterval) + | SourceConfig.Dynamo (indexContext, checkpoints, loadMode, startFromTail, batchSizeCutoff, tailSleepInterval, statsInterval) -> + Dynamo.start (log, storeLog) sink categories (indexContext, checkpoints, loadMode, startFromTail, batchSizeCutoff, tailSleepInterval, statsInterval) + | SourceConfig.Esdb (client, checkpoints, hydrateBodies, startFromTail, batchSize, tailSleepInterval, statsInterval) -> + Esdb.start log sink categories (client, checkpoints, hydrateBodies, startFromTail, batchSize, tailSleepInterval, statsInterval) + | SourceConfig.Sss (client, checkpoints, hydrateBodies, startFromTail, batchSize, tailSleepInterval, statsInterval) -> + Sss.start log sink categories (client, checkpoints, hydrateBodies, startFromTail, batchSize, tailSleepInterval, statsInterval) diff --git a/Sample/ECommerce.Equinox/ECommerce.Reactor/ConfirmedHandler.fs b/Sample/ECommerce.Equinox/ECommerce.Reactor/ConfirmedHandler.fs new file mode 100644 index 000000000..979889ec8 --- /dev/null +++ b/Sample/ECommerce.Equinox/ECommerce.Reactor/ConfirmedHandler.fs @@ -0,0 +1,19 @@ +module ECommerce.Reactor.ConfirmedHandler + +open ECommerce.Domain + +type Service internal (carts : ShoppingCart.Service, ingester : ConfirmedIngester.Service) = + + member _.TrySummarizeConfirmed(cartId) : Async = async { + let! cartSummary, originEpoch = carts.SummarizeWithOriginEpoch(cartId, ingester.ActiveIngestionEpochId) + return! ingester.TryIngestCartSummary(originEpoch, ConfirmedEpoch.ofShoppingCartView cartId cartSummary) } + +module Config = + + let create_ linger (sourceStore, cosmosStore) = + let carts = ShoppingCart.Config.create sourceStore + let ingester = ConfirmedIngester.Config.create linger cosmosStore + Service(carts, ingester) + let create store = + let defaultLinger = System.TimeSpan.FromMilliseconds 200. + create_ defaultLinger store diff --git a/Sample/ECommerce.Equinox/ECommerce.Reactor/ECommerce.Reactor.fsproj b/Sample/ECommerce.Equinox/ECommerce.Reactor/ECommerce.Reactor.fsproj new file mode 100644 index 000000000..41a5fce6f --- /dev/null +++ b/Sample/ECommerce.Equinox/ECommerce.Reactor/ECommerce.Reactor.fsproj @@ -0,0 +1,24 @@ + + + + Exe + net6.0 + 5 + + + + + + + + + + + + + + + + + + diff --git a/Sample/ECommerce.Equinox/ECommerce.Reactor/Program.fs b/Sample/ECommerce.Equinox/ECommerce.Reactor/Program.fs new file mode 100644 index 000000000..cd65852e1 --- /dev/null +++ b/Sample/ECommerce.Equinox/ECommerce.Reactor/Program.fs @@ -0,0 +1,140 @@ +module ECommerce.Reactor.Program + +open Serilog +open System +open System.Threading.Tasks + +let [] AppName = "ECommerce.Reactor" + +module Args = + + open Argu + + [] + type Parameters = + | [] Verbose + | [] PrometheusPort of int + | [] ConsumerGroupName of string + | [] MaxReadAhead of int + | [] MaxWriters of int + | [] StateIntervalM of float + | [] IdleDelayMs of int + | [] WakeForResults + | [] Cosmos of ParseResults + | [] Dynamo of ParseResults + | [] Esdb of ParseResults + interface IArgParserTemplate with + member a.Usage = a |> function + | Verbose -> "request Verbose Logging. Default: off." + | PrometheusPort _ -> "port from which to expose a Prometheus /metrics endpoint. Default: off (optional if environment variable PROMETHEUS_PORT specified)" + | 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: 8." + | StateIntervalM _ -> "Interval at which to report Propulsion Statistics. Default: 10" + | IdleDelayMs _ -> "Idle delay for scheduler. Default 1000ms" + | WakeForResults -> "Wake for all results to provide optimal throughput" + | Cosmos _ -> "specify CosmosDB input parameters." + | Dynamo _ -> "specify DynamoDB input parameters." + | Esdb _ -> "specify EventStoreDB input parameters." + + type Arguments(c : SourceArgs.Configuration, p : ParseResults) = + let maxReadAhead = p.GetResult(MaxReadAhead, 2) + let maxConcurrentProcessors = p.GetResult(MaxWriters, 8) + let consumerGroupName = p.GetResult ConsumerGroupName + member _.ProcessorParams() = Log.Information("Reacting... {consumerGroupName}, reading {maxReadAhead} ahead, {dop} writers", + consumerGroupName, maxReadAhead, maxConcurrentProcessors) + (consumerGroupName, maxReadAhead, maxConcurrentProcessors) + member val Verbose = p.Contains Verbose + member val PrometheusPort = p.TryGetResult PrometheusPort |> Option.orElseWith (fun () -> c.PrometheusPort) + member val CacheSizeMb = 10 + member val StatsInterval = TimeSpan.FromMinutes 1. + member val StateInterval = p.GetResult(StateIntervalM, 10.) |> TimeSpan.FromMinutes + member val PurgeInterval = TimeSpan.FromHours 1. + member val IdleDelay = p.GetResult(IdleDelayMs, 1000) |> TimeSpan.FromMilliseconds + member val WakeForResults = p.Contains WakeForResults + member val Store : Choice = + match p.GetSubCommand() with + | Cosmos a -> Choice1Of3 <| SourceArgs.Cosmos.Arguments(c, a) + | Dynamo a -> Choice2Of3 <| SourceArgs.Dynamo.Arguments(c, a) + | Esdb a -> Choice3Of3 <| SourceArgs.Esdb.Arguments(c, a) + | a -> p.Raise $"Unexpected Store subcommand %A{a}" + member x.VerboseStore = match x.Store with + | Choice1Of3 s -> s.Verbose + | Choice2Of3 s -> s.Verbose + | Choice3Of3 s -> s.Verbose + member x.DumpStoreMetrics = match x.Store with + | Choice1Of3 _ -> Equinox.CosmosStore.Core.Log.InternalMetrics.dump + | Choice2Of3 _ -> Equinox.DynamoStore.Core.Log.InternalMetrics.dump + | Choice3Of3 _ -> Equinox.EventStoreDb.Log.InternalMetrics.dump + + member x.ConnectStoreSourceAndTarget() : Store.Config * (ILogger -> string -> SourceConfig) * Store.Config= + let cache = Equinox.Cache (AppName, sizeMb = x.CacheSizeMb) + match x.Store with + | Choice1Of3 a -> + let context, monitored, leases = a.ConnectWithFeed() |> Async.RunSynchronously + let buildSourceConfig _log groupName = + let startFromTail, maxItems, tailSleepInterval, lagFrequency = a.MonitoringParams + let checkpointConfig = CosmosFeedConfig.Persistent (groupName, startFromTail, maxItems, lagFrequency) + SourceConfig.Cosmos (monitored, leases, checkpointConfig, tailSleepInterval, x.StatsInterval) + let store = Store.Config.Cosmos (context, cache) + store, buildSourceConfig, store + | Choice2Of3 a -> + let context = a.Connect() + let buildSourceConfig log groupName = + let indexContext, startFromTail, batchSizeCutoff, tailSleepInterval, streamsDop = a.MonitoringParams(log) + let checkpoints = a.CreateCheckpointStore(groupName, cache) + let load = Propulsion.DynamoStore.WithData (streamsDop, context) + SourceConfig.Dynamo (indexContext, checkpoints, load, startFromTail, batchSizeCutoff, tailSleepInterval, x.StatsInterval) + let store = Store.Config.Dynamo (context, cache) + store, buildSourceConfig, store + | Choice3Of3 a -> + let connection = a.Connect(Log.Logger, AppName, EventStore.Client.NodePreference.Leader) + let context = connection |> EventStoreContext.create + let store = Store.Config.Esdb (context, cache) + let targetStore = a.ConnectTarget(cache) + let buildSourceConfig log groupName = + let startFromTail, maxItems, tailSleepInterval = a.MonitoringParams(log) + let checkpoints = a.CreateCheckpointStore(groupName, targetStore) + let hydrateBodies = true + SourceConfig.Esdb (connection.ReadConnection, checkpoints, hydrateBodies, startFromTail, maxItems, tailSleepInterval, x.StatsInterval) + store, buildSourceConfig, targetStore + + /// Parse the commandline; can throw exceptions in response to missing arguments and/or `-h`/`--help` args + let parse tryGetConfigValue argv : Arguments = + let programName = System.Reflection.Assembly.GetEntryAssembly().GetName().Name + let parser = ArgumentParser.Create(programName = programName) + Arguments(SourceArgs.Configuration tryGetConfigValue, parser.ParseCommandLine argv) + +open Propulsion.Internal // AwaitKeyboardInterruptAsTaskCanceledException + +let build (args : Args.Arguments) = + let consumerGroupName, maxReadAhead, maxConcurrentStreams = args.ProcessorParams() + let store, buildSourceConfig, targetStore = args.ConnectStoreSourceAndTarget() + let log = Log.forGroup consumerGroupName // needs to have a `group` tag for Propulsion.Streams Prometheus metrics + let handle = Reactor.Config.create (store, targetStore) + let stats = Reactor.Stats(log, args.StatsInterval, args.StateInterval, args.VerboseStore, logExternalStats = args.DumpStoreMetrics) + let sink = Reactor.Config.StartSink(log, stats, handle, maxReadAhead, maxConcurrentStreams, + wakeForResults = args.WakeForResults, idleDelay = args.IdleDelay, purgeInterval = args.PurgeInterval) + let source, _awaitSource = + let sourceConfig = buildSourceConfig log consumerGroupName + Reactor.Config.StartSource(log, sink, sourceConfig) + sink, source + +let run (args : Args.Arguments) = async { + use _ = args.PrometheusPort |> Option.map startMetricsServer |> Option.toObj + let sink, source = build args + return! [| Async.AwaitKeyboardInterruptAsTaskCanceledException() + source.AwaitWithStopOnCancellation() + sink.AwaitWithStopOnCancellation() + |] |> Async.Parallel |> Async.Ignore } + +[] +let main argv = + try let args = Args.parse EnvVar.tryGet argv + let metrics = Sinks.tags AppName |> Sinks.equinoxAndPropulsionReactorMetrics + try Log.Logger <- LoggerConfiguration().Configure(verbose=args.Verbose).Sinks(metrics, args.VerboseStore).CreateLogger() + try run args |> Async.RunSynchronously; 0 + with e when not (e :? TaskCanceledException) -> Log.Fatal(e, "Exiting"); 2 + finally Log.CloseAndFlush() + with:? Argu.ArguParseException as e -> eprintfn $"%s{e.Message}"; 1 + | e -> eprintfn $"Exception %s{e.Message}"; 1 diff --git a/Sample/ECommerce.Equinox/ECommerce.Reactor/Reactor.fs b/Sample/ECommerce.Equinox/ECommerce.Reactor/Reactor.fs new file mode 100644 index 000000000..f6081741e --- /dev/null +++ b/Sample/ECommerce.Equinox/ECommerce.Reactor/Reactor.fs @@ -0,0 +1,65 @@ +module ECommerce.Reactor.Reactor + +open ECommerce.Domain +open Metrics + +/// Gathers stats based on the outcome of each Span processed for emission, at intervals controlled by `StreamsConsumer` +type Stats(log, statsInterval, stateInterval, verboseStore, ?logExternalStats) = + inherit Propulsion.Streams.Stats(log, statsInterval, stateInterval) + + let mutable ok, skipped, na = 0, 0, 0 + + override _.HandleOk res = + observeReactorOutcome res + match res with + | Outcome.Ok (used, unused) -> ok <- ok + used; skipped <- skipped + unused + | Outcome.Skipped count -> skipped <- skipped + count + | Outcome.NotApplicable count -> na <- na + count + override _.HandleExn(log, exn) = + Exception.dump verboseStore log exn + + override _.DumpStats() = + if ok <> 0 || skipped <> 0 || na <> 0 then + log.Information(" used {ok} skipped {skipped} n/a {na}", ok, skipped, na) + ok <- 0; skipped <- 0; na <- 0 + match logExternalStats with None -> () | Some f -> f Serilog.Log.Logger + base.DumpStats() + +let reactionCategories = [| ShoppingCart.CategoryName |] + +let handle + (cartSummary : ShoppingCartSummaryHandler.Service) + (confirmedCarts : ConfirmedHandler.Service) + stream span : Async = async { + match struct (stream, span) with + | ShoppingCart.Reactions.Decode (cartId, events) -> + match events with + | ShoppingCart.Reactions.Confirmed -> + let! _done = confirmedCarts.TrySummarizeConfirmed(cartId) in () + | _ -> () + match events with + | ShoppingCart.Reactions.StateChanged -> + let! worked, version' = cartSummary.TryIngestSummary(cartId) + let outcome = if worked then Outcome.Ok (1, Array.length span - 1) else Outcome.Skipped span.Length + return Propulsion.Sinks.StreamResult.OverrideNextIndex version', outcome + | _ -> return Propulsion.Sinks.StreamResult.AllProcessed, Outcome.NotApplicable span.Length + | x -> return failwith $"Invalid event %A{x}" } // should be filtered by isReactionStream + +module Config = + + let create (sourceStore, targetStore) = + let cartSummary = ShoppingCartSummaryHandler.Config.create (sourceStore, targetStore) + let confirmedCarts = ConfirmedHandler.Config.create (sourceStore, targetStore) + handle cartSummary confirmedCarts + +type Config private () = + + static member StartSink(log : Serilog.ILogger, stats : Stats, + handle,// : (FsCodec.StreamName * Propulsion.Sinks.Event[]) -> + // Async<(Propulsion.Sinks.StreamResult * Outcome)>, + maxReadAhead : int, maxConcurrentStreams : int, ?wakeForResults, ?idleDelay, ?purgeInterval) = + Propulsion.Sinks.Factory.StartConcurrent(log, maxReadAhead, maxConcurrentStreams, handle, stats, + ?wakeForResults = wakeForResults, ?idleDelay = idleDelay, ?purgeInterval = purgeInterval) + + static member StartSource(log, sink, sourceConfig) = + SourceConfig.start (log, Store.Metrics.log) sink reactionCategories sourceConfig diff --git a/Sample/ECommerce.Equinox/ECommerce.Reactor/ReactorMetrics.fs b/Sample/ECommerce.Equinox/ECommerce.Reactor/ReactorMetrics.fs new file mode 100644 index 000000000..5a167d558 --- /dev/null +++ b/Sample/ECommerce.Equinox/ECommerce.Reactor/ReactorMetrics.fs @@ -0,0 +1,31 @@ +module ECommerce.Reactor.Metrics + +let baseName stat = "Ecommerce_reactor_" + stat +let baseDesc desc = "Ecommerce: Reactor " + desc + +module private Counter = + + let private make (config : Prometheus.CounterConfiguration) name desc = + let ctr = Prometheus.Metrics.CreateCounter(name, desc, config) + fun tagValues (c : float) -> ctr.WithLabels(tagValues).Inc(c) + + let create (tagNames, tagValues) stat desc = + let config = Prometheus.CounterConfiguration(LabelNames = tagNames) + make config (baseName stat) (baseDesc desc) tagValues + +let observeOutcomeStatus s = Counter.create ([| "status" |],[| s |]) "outcome_total" "Outcome" + +[] +type Outcome = + /// Handler processed the span, with counts of used vs unused known event types + | Ok of used : int * unused : int + /// Handler processed the span, but idempotency checks resulted in no writes being applied; includes count of decoded events + | Skipped of count : int + /// Handler determined the events were not relevant to its duties and performed no actions + /// e.g. wrong category, events that dont imply a state change + | NotApplicable of count : int + +let observeReactorOutcome = function + | Outcome.Ok (used, _)-> observeOutcomeStatus "ok" (float used) + | Outcome.Skipped c -> observeOutcomeStatus "ignored" (float c) + | Outcome.NotApplicable c -> observeOutcomeStatus "handled" (float c) diff --git a/Sample/ECommerce.Equinox/ECommerce.Reactor/ShoppingCartSummaryHandler.fs b/Sample/ECommerce.Equinox/ECommerce.Reactor/ShoppingCartSummaryHandler.fs new file mode 100644 index 000000000..94c6ef970 --- /dev/null +++ b/Sample/ECommerce.Equinox/ECommerce.Reactor/ShoppingCartSummaryHandler.fs @@ -0,0 +1,19 @@ +module ECommerce.Reactor.ShoppingCartSummaryHandler + +open ECommerce.Domain + +type Service internal (source : ShoppingCart.Service, destination : ShoppingCartSummary.Service) = + + member _.TryIngestSummary(cartId) : Async = async { + match! source.SummarizeWithVersion(cartId) with + | Some view, version' -> + let! worked = destination.TryIngest(cartId, version', view) + return worked, version' + | None, version' -> return false, version' } + +module Config = + + let create (sourceStore, targetStore) = + let source = ShoppingCart.Config.create sourceStore + let destination = ShoppingCartSummary.Config.create targetStore + Service(source, destination) diff --git a/Sample/ECommerce.Equinox/ECommerce.Reactor/SourceArgs.fs b/Sample/ECommerce.Equinox/ECommerce.Reactor/SourceArgs.fs new file mode 100644 index 000000000..d19fd1945 --- /dev/null +++ b/Sample/ECommerce.Equinox/ECommerce.Reactor/SourceArgs.fs @@ -0,0 +1,256 @@ +module SourceArgs + +open Argu +open Serilog +open System + +type Configuration(tryGet) = + inherit Args.Configuration(tryGet) + member _.DynamoIndexTable = tryGet Args.INDEX_TABLE + +module Cosmos = + + type [] Parameters = + | [] Verbose + | [] ConnectionMode of Microsoft.Azure.Cosmos.ConnectionMode + | [] Connection of string + | [] Database of string + | [] Container of string + | [] Timeout of float + | [] Retries of int + | [] RetriesWaitTime of float + + | [] LeaseContainer of string + | [] FromTail + | [] MaxItems of int + | [] LagFreqM of float + interface IArgParserTemplate with + member p.Usage = p |> function + | Verbose -> "request Verbose Logging from ChangeFeedProcessor and Store. Default: off" + | 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 store. (optional if environment variable EQUINOX_COSMOS_DATABASE specified)" + | Container _ -> "specify a container name for store. (optional if environment variable EQUINOX_COSMOS_CONTAINER specified)" + | Timeout _ -> "specify operation timeout in seconds. Default: 5." + | Retries _ -> "specify operation retries. Default: 9." + | RetriesWaitTime _ -> "specify max wait-time for retry when being throttled by Cosmos in seconds. Default: 30." + + | LeaseContainer _ -> "specify Container Name (in this [target] Database) for Leases container. Default: `SourceContainer` + `-aux`." + | FromTail -> "(iff the Consumer Name is fresh) - force skip to present Position. Default: Never skip an event." + | MaxItems _ -> "maximum item count to supply for the Change Feed query. Default: use response size limit" + | LagFreqM _ -> "specify frequency (minutes) to dump lag stats. Default: 1" + + type Arguments(c: Args.Configuration, p: ParseResults) = + let discovery = p.GetResult(Connection, fun () -> c.CosmosConnection) |> Equinox.CosmosStore.Discovery.ConnectionString + let mode = p.TryGetResult ConnectionMode + let timeout = p.GetResult(Timeout, 5.) |> TimeSpan.FromSeconds + let retries = p.GetResult(Retries, 9) + let maxRetryWaitTime = p.GetResult(RetriesWaitTime, 30.) |> TimeSpan.FromSeconds + let connector = Equinox.CosmosStore.CosmosStoreConnector(discovery, timeout, retries, maxRetryWaitTime, ?mode = mode) + let database = p.GetResult(Database, fun () -> c.CosmosDatabase) + let containerId = p.GetResult(Container, fun () -> c.CosmosContainer) + let leaseContainerId = p.GetResult(LeaseContainer, containerId + "-aux") + let fromTail = p.Contains FromTail + let maxItems = p.TryGetResult MaxItems + let tailSleepInterval = TimeSpan.FromMilliseconds 500. + let lagFrequency = p.GetResult(LagFreqM, 1.) |> TimeSpan.FromMinutes + member val Verbose = p.Contains Verbose + member val MonitoringParams = fromTail, maxItems, tailSleepInterval, lagFrequency + member _.ConnectWithFeed() = connector.ConnectWithFeed(database, containerId, leaseContainerId) + +module Dynamo = + + type [] Parameters = + | [] Verbose + | [] RegionProfile of string + | [] ServiceUrl of string + | [] AccessKey of string + | [] SecretKey of string + | [] Table of string + | [] Retries of int + | [] RetriesTimeoutS of float + | [] IndexTable of string + | [] IndexSuffix of string + | [] MaxItems of int + | [] FromTail + | [] StreamsDop of int + interface IArgParserTemplate with + member p.Usage = p |> function + | Verbose -> "Include low level Store logging." + | RegionProfile _ -> "specify an AWS Region (aka System Name, e.g. \"us-east-1\") to connect to using the implicit AWS SDK/tooling config and/or environment variables etc. Optional if:\n" + + "1) $" + Args.REGION + " specified OR\n" + + "2) Explicit `ServiceUrl`/$" + Args.SERVICE_URL + "+`AccessKey`/$" + Args.ACCESS_KEY + "+`Secret Key`/$" + Args.SECRET_KEY + " specified.\n" + + "See https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-envvars.html for details" + | ServiceUrl _ -> "specify a server endpoint for a Dynamo account. (optional if environment variable " + Args.SERVICE_URL + " specified)" + | AccessKey _ -> "specify an access key id for a Dynamo account. (optional if environment variable " + Args.ACCESS_KEY + " specified)" + | SecretKey _ -> "specify a secret access key for a Dynamo account. (optional if environment variable " + Args.SECRET_KEY + " specified)" + | Retries _ -> "specify operation retries (default: 9)." + | RetriesTimeoutS _ -> "specify max wait-time including retries in seconds (default: 60)" + | Table _ -> "specify a table name for the primary store. (optional if environment variable " + Args.TABLE + " specified)" + | IndexTable _ -> "specify a table name for the index store. (optional if environment variable " + Args.INDEX_TABLE + " specified. default: `Table`+`IndexSuffix`)" + | IndexSuffix _ -> "specify a suffix for the index store. (optional if environment variable " + Args.INDEX_TABLE + " specified. default: \"-index\")" + | MaxItems _ -> "maximum events to load in a batch. Default: 100" + | FromTail -> "(iff the Consumer Name is fresh) - force skip to present Position. Default: Never skip an event." + | StreamsDop _ -> "parallelism when loading events from Store Feed Source. Default 4" + + type Arguments(c: Configuration, p: ParseResults) = + let conn = match p.TryGetResult RegionProfile |> Option.orElseWith (fun () -> c.DynamoRegion) with + | Some systemName -> + Choice1Of2 systemName + | None -> + let serviceUrl = p.GetResult(ServiceUrl, fun () -> c.DynamoServiceUrl) + let accessKey = p.GetResult(AccessKey, fun () -> c.DynamoAccessKey) + let secretKey = p.GetResult(SecretKey, fun () -> c.DynamoSecretKey) + Choice2Of2 (serviceUrl, accessKey, secretKey) + let connector = let timeout = p.GetResult(RetriesTimeoutS, 60.) |> TimeSpan.FromSeconds + let retries = p.GetResult(Retries, 9) + match conn with + | Choice1Of2 systemName -> + Equinox.DynamoStore.DynamoStoreConnector(systemName, timeout, retries) + | Choice2Of2 (serviceUrl, accessKey, secretKey) -> + Equinox.DynamoStore.DynamoStoreConnector(serviceUrl, accessKey, secretKey, timeout, retries) + let table = p.GetResult(Table, fun () -> c.DynamoTable) + let indexSuffix = p.GetResult(IndexSuffix, "-index") + let indexTable = p.GetResult(IndexTable, fun () -> defaultArg c.DynamoIndexTable (table + indexSuffix)) + let fromTail = p.Contains FromTail + let tailSleepInterval = TimeSpan.FromMilliseconds 500. + let batchSizeCutoff = p.GetResult(MaxItems, 100) + let streamsDop = p.GetResult(StreamsDop, 4) + let client = lazy connector.CreateClient() + let indexContext = lazy client.Value.CreateContext("Index", indexTable) + member val Verbose = p.Contains Verbose + member _.Connect() = client.Value.CreateContext("Main", table) + member _.MonitoringParams(log: ILogger) = + log.Information("DynamoStoreSource BatchSizeCutoff {batchSizeCutoff} Hydrater parallelism {streamsDop}", batchSizeCutoff, streamsDop) + let indexContext = indexContext.Value + if fromTail then log.Warning("(If new projector group) Skipping projection of all existing events.") + indexContext, fromTail, batchSizeCutoff, tailSleepInterval, streamsDop + member _.CreateCheckpointStore(group, cache) = + indexContext.Value.CreateCheckpointService(group, cache, Store.Metrics.log) + +module Esdb = + + type [] Parameters = + | [] Verbose + | [] Connection of string + | [] Credentials of string + | [] Timeout of float + | [] Retries of int + + | [] BatchSize of int + | [] FromTail + + | [] Cosmos of ParseResults + | [] Dynamo of ParseResults + interface IArgParserTemplate with + member p.Usage = p |> function + | Verbose -> "Include low level Store logging." + | Connection _ -> "EventStore Connection String. (optional if environment variable EQUINOX_ES_CONNECTION specified)" + | Credentials _ -> "Credentials string for EventStore (used as part of connection string, but NOT logged). Default: use EQUINOX_ES_CREDENTIALS environment variable (or assume no credentials)" + | Timeout _ -> "specify operation timeout in seconds. Default: 20." + | Retries _ -> "specify operation retries. Default: 3." + + | FromTail -> "Start the processing from the Tail" + | BatchSize _ -> "maximum events to load in a batch. Default: 100" + + | Cosmos _ -> "CosmosDB Target Store parameters (also used for checkpoint storage)." + | Dynamo _ -> "DynamoDB Target Store parameters (also used for checkpoint storage)." + type Arguments(c : Configuration, p : ParseResults) = + let startFromTail = p.Contains FromTail + let batchSize = p.GetResult(BatchSize, 100) + let tailSleepInterval = TimeSpan.FromSeconds 0.5 + let connectionStringLoggable = p.TryGetResult Connection |> Option.defaultWith (fun () -> c.EventStoreConnection) + let credentials = p.TryGetResult Credentials |> Option.orElseWith (fun () -> c.MaybeEventStoreCredentials) + let discovery = match credentials with Some x -> String.Join(";", connectionStringLoggable, x) | None -> connectionStringLoggable + |> Equinox.EventStoreDb.Discovery.ConnectionString + let retries = p.GetResult(Retries, 3) + let timeout = p.GetResult(Timeout, 20.) |> TimeSpan.FromSeconds + let checkpointInterval = TimeSpan.FromHours 1. + member val Verbose = p.Contains Verbose + + member _.Connect(log : ILogger, appName, nodePreference) : Equinox.EventStoreDb.EventStoreConnection = + log.Information("EventStore {discovery}", connectionStringLoggable) + let tags=["M", Environment.MachineName; "I", Guid.NewGuid() |> string] + Equinox.EventStoreDb.EventStoreConnector(timeout, retries, tags = tags) + .Establish(appName, discovery, Equinox.EventStoreDb.ConnectionStrategy.ClusterSingle nodePreference) + + member _.MonitoringParams(log : ILogger) = + log.Information("EventStoreSource BatchSize {batchSize} ", batchSize) + startFromTail, batchSize, tailSleepInterval + member _.CreateCheckpointStore(group, store) : Propulsion.Feed.IFeedCheckpointStore = + Args.Checkpoints.createCheckpointStore (group, checkpointInterval, store) + member private _.TargetStoreArgs : Args.StoreArgs = + match p.GetSubCommand() with + | Cosmos cosmos -> Args.StoreArgs.Cosmos (Args.Cosmos.Arguments(c, cosmos)) + | Dynamo dynamo -> Args.StoreArgs.Dynamo (Args.Dynamo.Arguments(c, dynamo)) + | _ -> p.Raise "Must specify `cosmos` or `dynamo` target store when source is `esdb`" + member x.ConnectTarget(cache) = + Args.StoreArgs.connectTarget x.TargetStoreArgs cache + +module Sss = + + // TOCONSIDER: add DB connectors other than MsSql + type [] Parameters = + | [] Tail of intervalS: float + | [] Connection of string + | [] Credentials of string + | [] Schema of string + + | [] BatchSize of int + | [] FromTail + + | [] CheckpointsConnection of string + | [] CheckpointsCredentials of string + + | [] Cosmos of ParseResults + | [] Dynamo of ParseResults + interface IArgParserTemplate with + member p.Usage = p |> function + | Tail _ -> "Polling interval in Seconds. Default: 1" + | BatchSize _ -> "Maximum events to request from feed. Default: 512" + | Connection _ -> "Connection string for SqlStreamStore db. Optional if SQLSTREAMSTORE_CONNECTION specified" + | Credentials _ -> "Credentials string for SqlStreamStore db (used as part of connection string, but NOT logged). Default: use SQLSTREAMSTORE_CREDENTIALS environment variable (or assume no credentials)" + | Schema _ -> "Database schema name" + | FromTail -> "Start the processing from the Tail" + | CheckpointsConnection _ ->"Connection string for Checkpoints sql db. Optional if SQLSTREAMSTORE_CONNECTION_CHECKPOINTS specified. Default: same as `Connection`" + | CheckpointsCredentials _ ->"Credentials string for Checkpoints sql db. (used as part of checkpoints connection string, but NOT logged). Default (when no `CheckpointsConnection`: use `Credentials. Default (when `CheckpointsConnection` specified): use SQLSTREAMSTORE_CREDENTIALS_CHECKPOINTS environment variable (or assume no credentials)" + | Cosmos _ -> "CosmosDB Target Store parameters" + | Dynamo _ -> "DynamoDB Target Store parameters" + + type Arguments(c : Configuration, p : ParseResults) = + let startFromTail = p.Contains FromTail + let tailSleepInterval = p.GetResult(Tail, 1.) |> TimeSpan.FromSeconds + let batchSize = p.GetResult(BatchSize, 512) + let connection = p.TryGetResult Connection |> Option.defaultWith (fun () -> c.SqlStreamStoreConnection) + let credentials = p.TryGetResult Credentials |> Option.orElseWith (fun () -> c.SqlStreamStoreCredentials) |> Option.toObj + let schema = p.GetResult(Schema, null) + + member x.BuildCheckpointsConnectionString() = + let c, cs = + match p.TryGetResult CheckpointsConnection, p.TryGetResult CheckpointsCredentials with + | Some c, Some p -> c, String.Join(";", c, p) + | None, Some p -> let c = connection in c, String.Join(";", c, p) + | None, None -> let c = connection in c, String.Join(";", c, credentials) + | Some cc, None -> let p = c.SqlStreamStoreCredentialsCheckpoints |> Option.toObj + cc, String.Join(";", cc, p) + Log.Information("Checkpoints MsSql Connection {connectionString}", c) + cs + member x.Connect() = + let conn, creds, schema, autoCreate = connection, credentials, schema, false + let sssConnectionString = String.Join(";", conn, creds) + Log.Information("SqlStreamStore MsSql Connection {connectionString} Schema {schema} AutoCreate {autoCreate}", conn, schema, autoCreate) + let rawStore = Equinox.SqlStreamStore.MsSql.Connector(sssConnectionString, schema, autoCreate=autoCreate).Connect() |> Async.RunSynchronously + Equinox.SqlStreamStore.SqlStreamStoreConnection rawStore + member _.MonitoringParams(log : ILogger) = + log.Information("SqlStreamStoreSource BatchSize {batchSize} ", batchSize) + startFromTail, batchSize, tailSleepInterval + member private _.TargetStoreArgs : Args.StoreArgs = + match p.GetSubCommand() with + | Cosmos cosmos -> Args.StoreArgs.Cosmos (Args.Cosmos.Arguments(c, cosmos)) + | Dynamo dynamo -> Args.StoreArgs.Dynamo (Args.Dynamo.Arguments(c, dynamo)) + | _ -> p.Raise "Must specify `cosmos` or `dynamo` target store when source is `sss`" + member x.CreateCheckpointStoreSql(groupName) : Propulsion.Feed.IFeedCheckpointStore = + let connectionString = x.BuildCheckpointsConnectionString() + Propulsion.SqlStreamStore.ReaderCheckpoint.Service(connectionString, groupName) + member x.ConnectTarget(cache) = + Args.StoreArgs.connectTarget x.TargetStoreArgs cache diff --git a/Sample/ECommerce.Equinox/ECommerce.Tests/ConfirmedIngesterTests.fs b/Sample/ECommerce.Equinox/ECommerce.Tests/ConfirmedIngesterTests.fs new file mode 100644 index 000000000..535c13eae --- /dev/null +++ b/Sample/ECommerce.Equinox/ECommerce.Tests/ConfirmedIngesterTests.fs @@ -0,0 +1,52 @@ +module ECommerce.Domain.Tests.ConfirmedIngesterTests + +open ECommerce.Domain +open FsCheck +open FsCheck.FSharp +open FsCheck.Xunit +open FSharp.UMX +open Swensen.Unquote + +let linger, maxItemsPerEpoch = System.TimeSpan.FromMilliseconds 1., 5 + +let createSut = + // While we use ~ 200ms when hitting Cosmos, there's no value in doing so in the context of these property based tests + ConfirmedIngester.Config.create_ maxItemsPerEpoch linger + +type GuidStringN<[]'m> = GuidStringN of string<'m> with static member op_Explicit(GuidStringN x) = x + +type Gap = Gap of int with static member op_Explicit(Gap i) = i + +let genDefault<'t> = ArbMap.defaults |> ArbMap.generate<'t> +type Custom = + + static member GuidStringN() = genDefault |> Gen.map (Guid.toStringN >> GuidStringN) |> Arb.fromGen + static member Gap() = Gen.choose (0, 3) |> Gen.map Gap |> Arb.fromGen + +[ |] )>] do() + +let [] properties shouldUseSameSut (Gap gap) (initialEpochId, NonEmptyArray initialItems) (NonEmptyArray items) = async { + + let store = Equinox.MemoryStore.VolatileStore() |> Store.Config.Memory + + let mutable nextEpochId = initialEpochId + for _ in 1 .. gap do nextEpochId <- ConfirmedEpochId.next nextEpochId + + // Initialize with some items + let initialSut = createSut store + let! initialResult = initialSut.IngestMany(initialEpochId, initialItems) + let initialExpected = initialItems |> Seq.map ConfirmedEpoch.itemId + test <@ set initialExpected = set initialResult @> + + // Add more, can be overlapping, adjacent + let sut = if shouldUseSameSut then initialSut else createSut store + let! result = sut.IngestMany(nextEpochId, items) + let expected = items |> Seq.map ConfirmedEpoch.itemId |> Seq.except initialExpected + + test <@ set expected = set result @> } + // TODO port to FsCheck v3 + // |> Prop.collect gap + // |> Prop.classify (initialItems.Length >= maxItemsPerEpoch) "fill" + // |> Prop.classify (gap = 0) "overlapping" + // |> Prop.classify (gap = 1) "adjacent" + // |> Prop.classify (items.Length >= maxItemsPerEpoch) "fillNext" diff --git a/Sample/ECommerce.Equinox/ECommerce.Tests/ECommerce.Tests.fsproj b/Sample/ECommerce.Equinox/ECommerce.Tests/ECommerce.Tests.fsproj new file mode 100644 index 000000000..70c4aad01 --- /dev/null +++ b/Sample/ECommerce.Equinox/ECommerce.Tests/ECommerce.Tests.fsproj @@ -0,0 +1,29 @@ + + + + net6.0 + 5 + false + + + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + +