Skip to content

Commit

Permalink
Add eqxPatterns template (#89)
Browse files Browse the repository at this point in the history
  • Loading branch information
bartelink authored May 12, 2021
1 parent 5a23ae8 commit 057adbf
Show file tree
Hide file tree
Showing 18 changed files with 947 additions and 0 deletions.
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

0 comments on commit 057adbf

Please sign in to comment.