Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Add eqxPatterns template #89

Merged
merged 20 commits into from
May 12, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ The `Unreleased` section name is replaced by the expected version of next releas

### Added

- `eqxPatterns`: `Period`: Skeleton Deciders+Tests for `Period` with Rolling Balance [#89](https://github.com/jet/dotnet-templates/pull/89)
- `eqxPatterns`: `Series`+`Epoch`: Skeleton Deciders+Tests for deduplicated ingestion of items [#89](https://github.com/jet/dotnet-templates/pull/89)
- `eqxProjector --source cosmos --kafka --synthesizeSequence`: Sample code for custom parsing of document changes [#84](https://github.com/jet/dotnet-templates/pull/84)

### Changed
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ These templates focus solely on Consistent Processing using Equinox Stores:
- [`eqxweb`](equinox-web/README.md) - Boilerplate for an ASP .NET Core 2 Web App, with an associated storage-independent Domain project using [Equinox](https://github.com/jet/equinox).
- [`eqxwebcs`](equinox-web-csharp/README.md) - Boilerplate for an ASP .NET Core 2 Web App, with an associated storage-independent Domain project using [Equinox](https://github.com/jet/equinox), _ported to C#_.
- [`eqxtestbed`](equinox-testbed/README.md) - Host that allows running back-to-back benchmarks when prototyping models using [Equinox](https://github.com/jet/equinox), using different stores and/or store configuration parameters.
- [`eqxPatterns`](equinox-patterns/README.md) - Equinox Skeleton Deciders and Tests implementing various event sourcing patterns:
- [x] Periods with Rolling Balance carried forward
- [x] Epochs/Series/Ingester with deduplication

## [Propulsion](https://github.com/jet/propulsion) related

Expand Down
20 changes: 20 additions & 0 deletions dotnet-templates.sln
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,16 @@ EndProjectSection
EndProject
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Pruner", "propulsion-pruner\Pruner.fsproj", "{A0FB44F5-15E5-47C8-81E5-1991269849CB}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "eqxPatterns", "eqxPatterns", "{76721F1E-851C-4970-A276-DF61FCE3DA23}"
ProjectSection(SolutionItems) = preProject
equinox-patterns\.template.config\template.json = equinox-patterns\.template.config\template.json
equinox-patterns\README.md = equinox-patterns\README.md
EndProjectSection
EndProject
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Domain", "equinox-patterns\Domain\Domain.fsproj", "{8D9867A9-1B5D-4AD3-A890-ACC81D011C00}"
EndProject
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Domain.Tests", "equinox-patterns\Domain.Tests\Domain.Tests.fsproj", "{C899EB07-FEC8-41F8-95DC-203DA80F5A32}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -191,6 +201,14 @@ Global
{A0FB44F5-15E5-47C8-81E5-1991269849CB}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A0FB44F5-15E5-47C8-81E5-1991269849CB}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A0FB44F5-15E5-47C8-81E5-1991269849CB}.Release|Any CPU.Build.0 = Release|Any CPU
{8D9867A9-1B5D-4AD3-A890-ACC81D011C00}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{8D9867A9-1B5D-4AD3-A890-ACC81D011C00}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8D9867A9-1B5D-4AD3-A890-ACC81D011C00}.Release|Any CPU.ActiveCfg = Release|Any CPU
{8D9867A9-1B5D-4AD3-A890-ACC81D011C00}.Release|Any CPU.Build.0 = Release|Any CPU
{C899EB07-FEC8-41F8-95DC-203DA80F5A32}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C899EB07-FEC8-41F8-95DC-203DA80F5A32}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C899EB07-FEC8-41F8-95DC-203DA80F5A32}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C899EB07-FEC8-41F8-95DC-203DA80F5A32}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{F66A5BFE-7C81-44DC-97DE-3FD8C83B8F06} = {B72FFAAE-7801-41B2-86F5-FD90E97A30F7}
Expand All @@ -210,5 +228,7 @@ Global
{83BA87C3-6288-40F4-BC4F-EC3A54586CDF} = {DAE9E2B9-EDA2-4064-B0CE-FD5294549374}
{B9976751-C3A6-4F8B-BEF4-278382D8EAA6} = {BBD6F425-C2F4-4857-BD68-2B13D0B4EDDE}
{A0FB44F5-15E5-47C8-81E5-1991269849CB} = {7F80222A-2687-4E91-8F5B-4717DD13DEF5}
{8D9867A9-1B5D-4AD3-A890-ACC81D011C00} = {76721F1E-851C-4970-A276-DF61FCE3DA23}
{C899EB07-FEC8-41F8-95DC-203DA80F5A32} = {76721F1E-851C-4970-A276-DF61FCE3DA23}
EndGlobalSection
EndGlobal
21 changes: 21 additions & 0 deletions equinox-patterns/.template.config/template.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"$schema": "http://json.schemastore.org/template",
"author": "@jet @bartelink",
"classifications": [
"Equinox",
"Event Sourcing",
"Closing Books",
"Epoch",
"Series",
"Period"
],
"tags": {
"language": "F#",
"type": "project"
},
"identity": "Equinox.Patterns",
"name": "Equinox Patterns Sample",
"shortName": "eqxPatterns",
"sourceName": "Patterns",
"preferNameDirectory": true
}
29 changes: 29 additions & 0 deletions equinox-patterns/Domain.Tests/Domain.Tests.fsproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>netcoreapp3.1</TargetFramework>
<WarningLevel>5</WarningLevel>
<IsPackable>false</IsPackable>
<OutputType>Library</OutputType>
</PropertyGroup>

<ItemGroup>
<Compile Include="Infrastructure.fs" />
<Compile Include="PeriodsCarryingForward.fs" />
<Compile Include="ItemIngesterTests.fs" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.5.0" />

<PackageReference Include="FsCheck.Xunit" Version="2.14.2" />
<PackageReference Include="unquote" Version="5.0.0" />
<PackageReference Include="xunit" Version="2.4.1" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.1" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\Domain\Domain.fsproj" />
</ItemGroup>

</Project>
34 changes: 34 additions & 0 deletions equinox-patterns/Domain.Tests/Infrastructure.fs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
[<AutoOpen>]
module Patterns.Domain.Tests.Infrastructure

open FSharp.UMX
open System
open FsCheck
open Patterns.Domain

(* Generic FsCheck helpers *)

let (|Id|) (x : Guid) = x.ToString "N" |> UMX.tag
let inline mkId () = Guid.NewGuid() |> (|Id|)
let (|Ids|) (xs : Guid[]) = xs |> Array.map (|Id|)

type DomainArbs() =

static member Item : Arbitrary<ItemEpoch.Events.Item> = Arb.fromGen <| gen {
let! r = Arb.Default.Derive() |> Arb.toGen
let id = mkId () // TODO why doesnt `let (Id id) = Arb.generate` generate fresh every time?
return { r with id = id }
}

type DomainProperty() = inherit FsCheck.Xunit.PropertyAttribute(Arbitrary=[|typeof<DomainArbs>|], QuietOnSuccess=true)

/// Inspired by AutoFixture.XUnit's AutoDataAttribute - generating test data without the full Property Based Tests experience
/// By using this instead of Property, the developer has
/// a) asserted by using this property instead of [<DomainProperty>]
/// b) indirectly validated by running the tests frequently locally in DEBUG mode
/// that running the test multiple times is not a useful thing to do
#if !DEBUG
type AutoDataAttribute() = inherit FsCheck.Xunit.PropertyAttribute(Arbitrary=[|typeof<DomainArbs>|], MaxTest=1, QuietOnSuccess=true)
#else
type AutoDataAttribute() = inherit FsCheck.Xunit.PropertyAttribute(Arbitrary=[|typeof<DomainArbs>|], MaxTest=5, QuietOnSuccess=true)
#endif
67 changes: 67 additions & 0 deletions equinox-patterns/Domain.Tests/ItemIngesterTests.fs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
module Patterns.Domain.Tests.ItemIngesterTests

open Patterns.Domain
open Patterns.Domain.ItemIngester
open FsCheck.Xunit
open FSharp.UMX
open Swensen.Unquote

let linger, lookBackLimit, maxPickTicketsPerBatch = System.TimeSpan.FromMilliseconds 1., 2, 5

let createSut store trancheId =
// While we use ~ 200ms when hitting Cosmos, there's no value in doing so in the context of these property based tests
let service = MemoryStore.Create(store, linger=linger, maxItemsPerEpoch=maxPickTicketsPerBatch, lookBackLimit=lookBackLimit)
service.ForTranche trancheId

let [<Property>] properties shouldInitialize shouldUseSameSut (Id trancheId) initialItems items =
let store = Equinox.MemoryStore.VolatileStore()
Async.RunSynchronously <| async {
// Initialize with some items
let initialSut = createSut store trancheId
if shouldInitialize then do! initialSut.Initialize()
let! initialResult = initialSut.IngestMany(initialItems)
let initialExpected = initialItems |> Seq.map ItemEpoch.itemId |> Array.ofSeq
test <@ set initialExpected = set initialResult @>

// Add some extra
let sut = if shouldUseSameSut then initialSut else createSut store trancheId
if shouldInitialize then do! sut.Initialize()
let! result = sut.IngestMany items
let expected = items |> Seq.map ItemEpoch.itemId |> Seq.except initialExpected |> Seq.distinct
test <@ set expected = set result @>

// Add the same stuff for a different tranche; the data should be completely independent from an ingestion perspective
let differentTranche = %(sprintf "%s2" %trancheId)
let differentSutSameStore = createSut store differentTranche
let! independentResult = differentSutSameStore.IngestMany(Array.append initialItems items)
test <@ set initialResult + set result = set independentResult @>
}

let [<AutoData>] ``lookBack is limited`` (Id trancheId) genItem =
let store = Equinox.MemoryStore.VolatileStore()
Async.RunSynchronously <| async {
// Initialize with more items than the lookBack accommodates
let initialSut = createSut store trancheId
let itemCount =
// Fill up lookBackLimit batches, and another one as batch 0 that we will not look include in the load
(lookBackLimit+1) * maxPickTicketsPerBatch
// Add one more so we end up with an active batchId = lookBackLimit
+ 1
let items = Array.init itemCount (fun _ -> genItem () )
test <@ Array.distinct items = items @>
let batch0 = Array.take maxPickTicketsPerBatch items
let batchesInLookBack = Array.skip maxPickTicketsPerBatch items
let! b0Added = initialSut.IngestMany batch0
let b0Added = Array.ofSeq b0Added
test <@ maxPickTicketsPerBatch = Array.length b0Added @>
let! batchesInLookBackAdded = initialSut.IngestMany batchesInLookBack
test <@ itemCount = Set.count (set b0Added + set batchesInLookBackAdded) @>

// Now try to add the same items - the first batch worth should not be deduplicated
let sut = createSut store trancheId
let! result = sut.IngestMany items
let result = Array.ofSeq result
test <@ itemCount = itemCount
&& result.Length = maxPickTicketsPerBatch
&& set result = set b0Added @>
}
33 changes: 33 additions & 0 deletions equinox-patterns/Domain.Tests/PeriodsCarryingForward.fs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/// Integration suite for `Period`
module Patterns.Domain.Tests.PeriodsCarryingForward

open Patterns.Domain
open Patterns.Domain.Period
open FSharp.UMX
open Swensen.Unquote
open Xunit

[<Fact>]
let ``Happy path`` () =
let store = Equinox.MemoryStore.VolatileStore()
let service = MemoryStore.create store
let decide items _state =
let apply = Array.truncate 2 items
let overflow = Array.skip apply.Length items
(match overflow with [||] -> None | xs -> Some xs), // Apply max of two events
(), // result
[Events.Added {items = apply }]
let add period events = service.Transact(PeriodId.parse period, decide, events) |> Async.RunSynchronously
let read period = service.Read(PeriodId.parse period) |> Async.RunSynchronously
add 0 [| %"a"; %"b" |]
test <@ Fold.Open [| %"a"; %"b"|] = read 0 @>
add 1 [| %"c"; %"d" |]
test <@ Fold.Closed ([| %"a"; %"b"|], [| %"a"; %"b"|]) = read 0 @>
test <@ Fold.Open [| %"a"; %"b"; %"c"; %"d" |] = read 1 @>
let items period = read period |> Fold.(|Items|)
add 1 [| %"e"; %"f"; %"g" |] // >2 items, therefore triggers an overflow
test <@ [| %"a"; %"b"; %"c"; %"d"; %"e"; %"f" |] = items 1 @>
test <@ [| %"a"; %"b"; %"c"; %"d"; %"e"; %"f"; %"g" |] = items 2 @>
test <@ Fold.Initial = read 3 @>
add 3 [| %"h" |]
test <@ Fold.Open [| %"a"; %"b"; %"c"; %"d"; %"e"; %"f"; %"g"; %"h" |] = read 3 @>
23 changes: 23 additions & 0 deletions equinox-patterns/Domain/Domain.fsproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<!-- We could target netstandard2.0 but we're slipping in a CosmosStore reference -->
<TargetFramework>netcoreapp3.1</TargetFramework>
</PropertyGroup>

<ItemGroup>
<Compile Include="Infrastructure.fs" />
<Compile Include="Types.fs" />
<Compile Include="Period.fs" />
<Compile Include="ItemEpoch.fs" />
<Compile Include="ItemSeries.fs" />
<Compile Include="ItemIngester.fs" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="Equinox.MemoryStore" Version="3.0.0-beta.4" />
<PackageReference Include="Equinox.CosmosStore" Version="3.0.0-beta.4" />
<PackageReference Include="FsCodec.NewtonsoftJson" Version="2.0.1" />
</ItemGroup>

</Project>
43 changes: 43 additions & 0 deletions equinox-patterns/Domain/Infrastructure.fs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
[<AutoOpen>]
module Patterns.Domain.Infrastructure

module Equinox =

let createDecider stream =
Equinox.Decider(Serilog.Log.Logger, stream, maxAttempts = 3)

/// Buffers events accumulated from a series of decisions while evolving the presented `state` to reflect said proposed `Events`
type Accumulator<'event, 'state>(originState, fold : 'state -> 'event seq -> 'state) =
let pendingEvents = ResizeArray()
let mutable state = originState

let apply (events : 'event seq) =
pendingEvents.AddRange events
state <- fold state events

/// Run a decision function, buffering and applying any Events yielded
member _.Transact decide =
let r, events = decide state
apply events
r

/// Run a decision function that does not yield a result
// member x.Transact decide =
// x.Transact(fun state -> (), decide state)

/// Run an Async decision function, buffering and applying any Events yielded
member _.TransactAsync decide = async {
let! r, events = decide state
apply events
return r }

/// Run an Async decision function that does not yield a result
member x.TransactAsync decide = async {
let! events = decide state
apply events }

// /// Projects from the present state including accumulated events
// member _.Query f = f state

/// Accumulated events based on the Decisions applied to date
member _.Events = List.ofSeq pendingEvents
Loading