├── .config
└── dotnet-tools.json
├── .gitattributes
├── .gitignore
├── .vscode
├── launch.json
└── tasks.json
├── CONTRIBUTING.md
├── GitVersion.yml
├── License.md
├── README.md
├── azure-pipelines.yml
├── build.ps1
└── src
└── SimpleEventStore
├── Directory.Build.props
├── SimpleEventStore.CosmosDb.Tests
├── AzureCosmosDbEventStoreAppending.cs
├── AzureCosmosDbEventStoreAppendingWithConverters.cs
├── AzureCosmosDbEventStoreInitializing.cs
├── AzureCosmosDbEventStoreLogging.cs
├── AzureCosmosDbEventStoreReading.cs
├── AzureCosmosDbEventStoreReadingPartiallyDeletedStreams.cs
├── AzureCosmosDbStorageEngineBuilderTests.cs
├── ConfigurableTypeMapSerializationBinderTests.cs
├── CosmosClientFactory.cs
├── CosmosDbStorageEngineFactory.cs
├── CosmosDbStorageEventTests.cs
├── PerformanceTests.cs
├── ResponseInformationBuilding.cs
├── SimpleEventStore.CosmosDb.Tests.csproj
├── TestConstants.cs
└── appsettings.json
├── SimpleEventStore.CosmosDb
├── AzureCosmosDbStorageEngine.cs
├── AzureCosmosDbStorageEngineBuilder.cs
├── CollectionOptions.cs
├── ConfigurableSerializationTypeMap.cs
├── CosmosDbStorageEvent.cs
├── DatabaseOptions.cs
├── DefaultSerializationTypeMap.cs
├── ISerializationTypeMap.cs
├── JsonNetCosmosSerializer.cs
├── LoggingOptions.cs
├── Resources.cs
├── ResponseInformation.cs
├── SimpleEventStore.AzureDocumentDb.nuspec
└── SimpleEventStore.CosmosDb.csproj
├── SimpleEventStore.Tests
├── EventDataTests.cs
├── EventStoreAppending.cs
├── EventStoreReading.cs
├── EventStoreTestBase.cs
├── Events
│ ├── OrderCreated.cs
│ ├── OrderDispatched.cs
│ ├── OrderProcessed.cs
│ └── TestMetadata.cs
├── InMemory
│ ├── InMemoryEventStoreAppending.cs
│ └── InMemoryEventStoreReading.cs
├── SimpleEventStore.Tests.csproj
└── StorageEventTests.cs
├── SimpleEventStore.sln
└── SimpleEventStore
├── ConcurrencyException.cs
├── EventData.cs
├── EventStore.cs
├── Guard.cs
├── IStorageEngine.cs
├── InMemory
└── InMemoryStorageEngine.cs
├── SimpleEventStore.csproj
└── StorageEvent.cs
/.config/dotnet-tools.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": 1,
3 | "isRoot": true,
4 | "tools": {
5 | "gitversion.tool": {
6 | "version": "5.1.2",
7 | "commands": [
8 | "dotnet-gitversion"
9 | ]
10 | }
11 | }
12 | }
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | ###############################################################################
2 | # Set default behavior to automatically normalize line endings.
3 | ###############################################################################
4 | * text=auto
5 |
6 | ###############################################################################
7 | # Set default behavior for command prompt diff.
8 | #
9 | # This is need for earlier builds of msysgit that does not have it on by
10 | # default for csharp files.
11 | # Note: This is only used by command line
12 | ###############################################################################
13 | #*.cs diff=csharp
14 |
15 | ###############################################################################
16 | # Set the merge driver for project and solution files
17 | #
18 | # Merging from the command prompt will add diff markers to the files if there
19 | # are conflicts (Merging from VS is not affected by the settings below, in VS
20 | # the diff markers are never inserted). Diff markers may cause the following
21 | # file extensions to fail to load in VS. An alternative would be to treat
22 | # these files as binary and thus will always conflict and require user
23 | # intervention with every merge. To do so, just uncomment the entries below
24 | ###############################################################################
25 | #*.sln merge=binary
26 | #*.csproj merge=binary
27 | #*.vbproj merge=binary
28 | #*.vcxproj merge=binary
29 | #*.vcproj merge=binary
30 | #*.dbproj merge=binary
31 | #*.fsproj merge=binary
32 | #*.lsproj merge=binary
33 | #*.wixproj merge=binary
34 | #*.modelproj merge=binary
35 | #*.sqlproj merge=binary
36 | #*.wwaproj merge=binary
37 |
38 | ###############################################################################
39 | # behavior for image files
40 | #
41 | # image files are treated as binary by default.
42 | ###############################################################################
43 | #*.jpg binary
44 | #*.png binary
45 | #*.gif binary
46 |
47 | ###############################################################################
48 | # diff behavior for common document formats
49 | #
50 | # Convert binary document formats to text before diffing them. This feature
51 | # is only available from the command line. Turn it on by uncommenting the
52 | # entries below.
53 | ###############################################################################
54 | #*.doc diff=astextplain
55 | #*.DOC diff=astextplain
56 | #*.docx diff=astextplain
57 | #*.DOCX diff=astextplain
58 | #*.dot diff=astextplain
59 | #*.DOT diff=astextplain
60 | #*.pdf diff=astextplain
61 | #*.PDF diff=astextplain
62 | #*.rtf diff=astextplain
63 | #*.RTF diff=astextplain
64 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | ## Ignore Visual Studio temporary files, build results, and
2 | ## files generated by popular Visual Studio add-ons.
3 |
4 | # User-specific files
5 | *.suo
6 | *.user
7 | *.userosscache
8 | *.sln.docstates
9 |
10 | # User-specific files (MonoDevelop/Xamarin Studio)
11 | *.userprefs
12 |
13 | # Build results
14 | [Dd]ebug/
15 | [Dd]ebugPublic/
16 | [Rr]elease/
17 | [Rr]eleases/
18 | x64/
19 | x86/
20 | bld/
21 | [Bb]in/
22 | [Oo]bj/
23 | [Ll]og/
24 |
25 | # Visual Studio 2015 cache/options directory
26 | .vs/
27 | # Uncomment if you have tasks that create the project's static files in wwwroot
28 | #wwwroot/
29 |
30 | # MSTest test Results
31 | [Tt]est[Rr]esult*/
32 | [Bb]uild[Ll]og.*
33 |
34 | # NUNIT
35 | *.VisualState.xml
36 | TestResult.xml
37 |
38 | # Build Results of an ATL Project
39 | [Dd]ebugPS/
40 | [Rr]eleasePS/
41 | dlldata.c
42 |
43 | # DNX
44 | project.lock.json
45 | artifacts/
46 |
47 | *_i.c
48 | *_p.c
49 | *_i.h
50 | *.ilk
51 | *.meta
52 | *.obj
53 | *.pch
54 | *.pdb
55 | *.pgc
56 | *.pgd
57 | *.rsp
58 | *.sbr
59 | *.tlb
60 | *.tli
61 | *.tlh
62 | *.tmp
63 | *.tmp_proj
64 | *.log
65 | *.vspscc
66 | *.vssscc
67 | .builds
68 | *.pidb
69 | *.svclog
70 | *.scc
71 |
72 | # Chutzpah Test files
73 | _Chutzpah*
74 |
75 | # Visual C++ cache files
76 | ipch/
77 | *.aps
78 | *.ncb
79 | *.opendb
80 | *.opensdf
81 | *.sdf
82 | *.cachefile
83 | *.VC.db
84 | *.VC.VC.opendb
85 |
86 | # Visual Studio profiler
87 | *.psess
88 | *.vsp
89 | *.vspx
90 | *.sap
91 |
92 | # TFS 2012 Local Workspace
93 | $tf/
94 |
95 | # Guidance Automation Toolkit
96 | *.gpState
97 |
98 | # ReSharper is a .NET coding add-in
99 | _ReSharper*/
100 | *.[Rr]e[Ss]harper
101 | *.DotSettings.user
102 |
103 | # JustCode is a .NET coding add-in
104 | .JustCode
105 |
106 | # TeamCity is a build add-in
107 | _TeamCity*
108 |
109 | # DotCover is a Code Coverage Tool
110 | *.dotCover
111 |
112 | # NCrunch
113 | _NCrunch_*
114 | .*crunch*.local.xml
115 | nCrunchTemp_*
116 |
117 | # MightyMoose
118 | *.mm.*
119 | AutoTest.Net/
120 |
121 | # Web workbench (sass)
122 | .sass-cache/
123 |
124 | # Installshield output folder
125 | [Ee]xpress/
126 |
127 | # DocProject is a documentation generator add-in
128 | DocProject/buildhelp/
129 | DocProject/Help/*.HxT
130 | DocProject/Help/*.HxC
131 | DocProject/Help/*.hhc
132 | DocProject/Help/*.hhk
133 | DocProject/Help/*.hhp
134 | DocProject/Help/Html2
135 | DocProject/Help/html
136 |
137 | # Click-Once directory
138 | publish/
139 |
140 | # Publish Web Output
141 | *.[Pp]ublish.xml
142 | *.azurePubxml
143 | # TODO: Comment the next line if you want to checkin your web deploy settings
144 | # but database connection strings (with potential passwords) will be unencrypted
145 | *.pubxml
146 | *.publishproj
147 |
148 | # Microsoft Azure Web App publish settings. Comment the next line if you want to
149 | # checkin your Azure Web App publish settings, but sensitive information contained
150 | # in these scripts will be unencrypted
151 | PublishScripts/
152 |
153 | # NuGet Packages
154 | *.nupkg
155 | # The packages folder can be ignored because of Package Restore
156 | **/packages/*
157 | # except build/, which is used as an MSBuild target.
158 | !**/packages/build/
159 | # Uncomment if necessary however generally it will be regenerated when needed
160 | #!**/packages/repositories.config
161 | # NuGet v3's project.json files produces more ignoreable files
162 | *.nuget.props
163 | *.nuget.targets
164 |
165 | # Microsoft Azure Build Output
166 | csx/
167 | *.build.csdef
168 |
169 | # Microsoft Azure Emulator
170 | ecf/
171 | rcf/
172 |
173 | # Windows Store app package directories and files
174 | AppPackages/
175 | BundleArtifacts/
176 | Package.StoreAssociation.xml
177 | _pkginfo.txt
178 |
179 | # Visual Studio cache files
180 | # files ending in .cache can be ignored
181 | *.[Cc]ache
182 | # but keep track of directories ending in .cache
183 | !*.[Cc]ache/
184 |
185 | # Others
186 | ClientBin/
187 | ~$*
188 | *~
189 | *.dbmdl
190 | *.dbproj.schemaview
191 | *.pfx
192 | *.publishsettings
193 | node_modules/
194 | orleans.codegen.cs
195 |
196 | # Since there are multiple workflows, uncomment next line to ignore bower_components
197 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
198 | #bower_components/
199 |
200 | # RIA/Silverlight projects
201 | Generated_Code/
202 |
203 | # Backup & report files from converting an old project file
204 | # to a newer Visual Studio version. Backup files are not needed,
205 | # because we have git ;-)
206 | _UpgradeReport_Files/
207 | Backup*/
208 | UpgradeLog*.XML
209 | UpgradeLog*.htm
210 |
211 | # SQL Server files
212 | *.mdf
213 | *.ldf
214 |
215 | # Business Intelligence projects
216 | *.rdl.data
217 | *.bim.layout
218 | *.bim_*.settings
219 |
220 | # Microsoft Fakes
221 | FakesAssemblies/
222 |
223 | # GhostDoc plugin setting file
224 | *.GhostDoc.xml
225 |
226 | # Node.js Tools for Visual Studio
227 | .ntvs_analysis.dat
228 |
229 | # Visual Studio 6 build log
230 | *.plg
231 |
232 | # Visual Studio 6 workspace options file
233 | *.opt
234 |
235 | # Visual Studio LightSwitch build output
236 | **/*.HTMLClient/GeneratedArtifacts
237 | **/*.DesktopClient/GeneratedArtifacts
238 | **/*.DesktopClient/ModelManifest.xml
239 | **/*.Server/GeneratedArtifacts
240 | **/*.Server/ModelManifest.xml
241 | _Pvt_Extensions
242 |
243 | # Paket dependency manager
244 | .paket/paket.exe
245 | paket-files/
246 |
247 | # FAKE - F# Make
248 | .fake/
249 |
250 | # JetBrains Rider
251 | .idea/
252 | *.sln.iml
253 |
254 | # Custom ignores
255 | /output
256 |
--------------------------------------------------------------------------------
/.vscode/launch.json:
--------------------------------------------------------------------------------
1 | {
2 | // Use IntelliSense to find out which attributes exist for C# debugging
3 | // Use hover for the description of the existing attributes
4 | // For further information visit https://github.com/OmniSharp/omnisharp-vscode/blob/master/debugger-launchjson.md
5 | "version": "0.2.0",
6 | "configurations": [
7 | {
8 | "name": ".NET Core Attach",
9 | "type": "coreclr",
10 | "request": "attach",
11 | "processId": "${command:pickProcess}"
12 | }
13 | ]
14 | }
--------------------------------------------------------------------------------
/.vscode/tasks.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "2.0.0",
3 | "command": "dotnet",
4 | "args": [],
5 | "tasks": [
6 | {
7 | "label": "build",
8 | "type": "shell",
9 | "command": "powershell",
10 | "args": [
11 | "./build.ps1",
12 | "-ConsistencyLevel",
13 | "Session"
14 | ],
15 | "problemMatcher": "$msCompile",
16 | "group": "build"
17 | }
18 | ]
19 | }
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing
2 |
3 | ## Building Simple Event Store
4 | Clone repository
5 | ```sh
6 | git clone git@github.com:ASOS/SimpleEventStore.git
7 | ```
8 |
9 | Enter the repository and build the provider and run the tests using cake
10 | ```sh
11 | cd build
12 | ./build.ps1
13 | ```
14 |
--------------------------------------------------------------------------------
/GitVersion.yml:
--------------------------------------------------------------------------------
1 | mode: Mainline
2 | branches: {}
3 | ignore:
4 | sha: []
--------------------------------------------------------------------------------
/License.md:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2017 ASOS
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Simple Event Store
2 |
3 | Simple Event Store (SES) provides a lightweight event sourcing abstraction.
4 |
5 | ## Key Features
6 | - Full support for async/await for all persistence engines
7 | - Optimistic concurrency for append operations
8 | - Reading streams forward
9 |
10 | ## Persistence Engines
11 | SES supports the following
12 | - Azure Cosmos DB
13 | - In Memory
14 |
15 | All persistence engines run the same test scenarios to ensure feature parity. At present only the Cosmos DB persistence engine should be considered for production usage.
16 |
17 | ## Usage
18 |
19 | ### Creation
20 | ```csharp
21 | var storageEngine = new InMemoryStorageEngine();
22 | var eventStore = new EventStore(storageEngine);
23 | ```
24 | Do not use storage engines directly, only interact with the event store using the EventStore class. There should only be a single instance of the EventStore per process. Creating transient instances will lead to a decrease in performance.
25 |
26 | ### Appending Events
27 | ```csharp
28 | var expectedRevision = 0;
29 |
30 | await eventStore.AppendToStream(streamId, expectedRevision, new EventData(Guid.NewGuid(), new OrderCreated(streamId)));
31 | ```
32 | The expected stream revision would either be set to 0 for a new stream, or to the expected event number. If the latest event number in the database differs then a concurrency exception will be thrown.
33 |
34 | ### Reading Events
35 | ```csharp
36 | var events = await eventStore.ReadStreamForwards(streamId, startPosition: 2, numberOfEventsToRead: 1);
37 | // or
38 | var events = await eventStore.ReadStreamForwards(streamId);
39 | ```
40 | You can either read all events in a stream, or a subset of events. Only read all events if you know the maximum size of a stream is going to be low and that you always need to read all events as part of your workload e.g. replaying events to project current state for a DDD aggregate.
41 |
42 | ## Cosmos DB
43 | ```csharp
44 | CosmosClient client; // Set this up as required
45 |
46 | // If UseCollection isn't specified, sensible defaults for development are used.
47 | // If UseSubscriptions isn't supplied the subscription feature is disabled.
48 | // If UseSharedThroughput isn't supplied the throughput is set only at a collection level
49 | return await new AzureCosmosDbStorageEngineBuilder(client, databaseName)
50 | .UseSharedThroughput(o => {
51 | o.DatabaseRequestUnits = 400;
52 | })
53 | .UseCollection(o =>
54 | {
55 | o.ConsistencyLevel = consistencyLevelEnum;
56 | o.CollectionRequestUnits = 400;
57 | })
58 | .UseLogging(o =>
59 | {
60 | o.Success = onSuccessCallback;
61 | })
62 | .UseTypeMap(new ConfigurableSerializationTypeMap()
63 | .RegisterTypes(
64 | typeof(OrderCreated).GetTypeInfo().Assembly,
65 | t => t.Namespace.EndsWith("Events"),
66 | t => t.Name))
67 | .UseJsonSerializerSettings(settings)
68 | .Build()
69 | .Initialise();
70 | ```
71 | ### Database Options
72 | Use this only if you want throughput to be set at a database level
73 |
74 | Allows you to specify
75 | - The number of RUs for the database
76 |
77 |
78 | ### CollectionOptions
79 | Allows you to specify
80 | - The consistency level of the database
81 | - The number of RUs for the collection - if throughput is set at a database level this cannot be greater than database throughput
82 | - The collection name
83 |
84 | Only use one of the following consistency levels
85 | - Strong
86 | - Bounded Staleness - use this if you need to geo-replicate the database
87 |
88 | ### UseLogging
89 | Sets up callbacks per Cosmos DB operation performed. This is useful if you want to record per call data e.g. RU cost of each operation.
90 |
91 | ### UseTypeMap
92 | Allows you to control the event body/metadata type names. Built in implementations
93 | - DefaultSerializationTypeMap - uses the AssemblyQualifiedName of the type. (default)
94 | - ConfigurableSerializationTypeMap - provides full control.
95 |
96 | While the default implementation is simple, this isn't great for versioning as contract assembly version number changes will render events unreadable. Therefore the configurable implementation or your own implementation is recommended.
97 |
98 | ### UseJsonSerializerSettings
99 | Allows you to customise JSON serialization of the event body and metadata. If you do not call this method a default JsonSerializerSettings instance is used.
100 |
101 | For consistent serialization provide the same serialzer settings to your DocumentClient.
102 |
103 | ### Initialise
104 | Calling the operation creates the underlying collection based on the DatabaseOptions. This ensures the required stored procedure is present too. It is safe to call this multiple times.
105 |
--------------------------------------------------------------------------------
/azure-pipelines.yml:
--------------------------------------------------------------------------------
1 | # https://aka.ms/yaml
2 |
3 | trigger:
4 | - main
5 | - main
6 |
7 | pr:
8 | - main
9 |
10 | pool:
11 | vmImage: windows-latest
12 | stages:
13 | - stage: Build
14 | displayName: Build
15 | jobs:
16 | - job: Build
17 | steps:
18 | - checkout: self
19 | fetchDepth: 0
20 | - task: gitversion/setup@0
21 | displayName: Install GitVersion
22 | inputs:
23 | versionSpec: '5.x'
24 | - task: gitversion/execute@0
25 | displayName: Determine GitVersion
26 | inputs:
27 | useConfigFile: true
28 | configFilePath: $(Build.SourcesDirectory)/GitVersion.yml
29 | targetPath: $(Build.SourcesDirectory)
30 | - task: PowerShell@2
31 | displayName: Starting Cosmos DB Emulator
32 | inputs:
33 | containerName: 'azure-cosmosdb-emulator'
34 | enableAPI: 'SQL'
35 | portMapping: '8081:8081, 8901:8901, 8902:8902, 8979:8979, 10250:10250, 10251:10251, 10252:10252, 10253:10253, 10254:10254, 10255:10255, 10256:10256, 10350:10350'
36 | hostDirectory: '$(Build.BinariesDirectory)\azure-cosmosdb-emulator'
37 | consistency: 'BoundedStaleness'
38 | targetType: 'inline'
39 | workingDirectory: $(Pipeline.Workspace)
40 | script: |
41 | Write-Host "Starting CosmosDB Emulator"
42 | Import-Module "C:/Program Files/Azure Cosmos DB Emulator/PSModules/Microsoft.Azure.CosmosDB.Emulator"
43 | Start-CosmosDbEmulator
44 |
45 | - task: PowerShell@2
46 | displayName: Build and Test
47 | inputs:
48 | filePath: '.\build.ps1'
49 | # arguments: '-Uri $(CosmosDbEmulator.Endpoint)'
50 | # - task: PublishTestResults@2
51 | # condition: succeededOrFailed()
52 | # inputs:
53 | # testRunner: VSTest
54 | # testResultsFiles: '**/*.trx'
55 |
56 | - task: DotNetCoreCLI@2
57 | displayName: Package
58 | inputs:
59 | command: "pack"
60 | versioningScheme: byEnvVar
61 | versionEnvVar: NuGetVersion
62 | packDestination: $(Build.ArtifactStagingDirectory)/package
63 | arguments: "-o $(Build.ArtifactStagingDirectory)/package"
64 | projects: |
65 | **/*.csproj
66 |
67 | - task: PublishBuildArtifacts@1
68 | displayName: Publish Build Artifacts
69 | inputs:
70 | PathtoPublish: '$(Build.ArtifactStagingDirectory)'
71 | ArtifactName: 'drop'
72 | publishLocation: 'Container'
73 |
74 | - stage: Deploy
75 | displayName: Deploy
76 | jobs:
77 | - job: Deploy
78 | condition:
79 | or(
80 | in(variables['Build.SourceBranch'], 'refs/heads/main'),
81 | in(variables['Build.Reason'], 'Manual')
82 | )
83 | steps:
84 | - task: DownloadBuildArtifacts@0
85 | displayName: 'Download artifacts'
86 | inputs:
87 | buildType: 'current'
88 | downloadType: 'single'
89 | artifactName: 'drop'
90 | itemPattern:
91 | downloadPath: '$(System.ArtifactsDirectory)'
92 |
93 | - task: NuGetCommand@2
94 | displayName: Publish package to nuget.org using ASOS organisation account
95 | inputs:
96 | command: 'push'
97 | packagesToPush: '$(System.ArtifactsDirectory)/drop/*.nupkg;!$(System.ArtifactsDirectory)/drop/*.symbols.nupkg'
98 | nuGetFeedType: 'external'
99 | publishFeedCredentials: 'ASOS nuget.org feed'
--------------------------------------------------------------------------------
/build.ps1:
--------------------------------------------------------------------------------
1 | Param(
2 | [string]$Uri = "https://localhost:8081/",
3 | [string]$AuthKey = "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==",
4 | [string]$ConsistencyLevel = "BoundedStaleness",
5 | [string]$Configuration = "Release"
6 | )
7 |
8 | function Write-Stage([string]$name)
9 | {
10 | Write-Host $('=' * 30) -ForegroundColor Green
11 | Write-Host $name -ForegroundColor Green
12 | Write-Host $('=' * 30) -ForegroundColor Green
13 | }
14 |
15 | function Exec
16 | {
17 | [CmdletBinding()]
18 | param(
19 | [Parameter(Position=0,Mandatory=1)][scriptblock]$cmd,
20 | [Parameter(Position=1,Mandatory=0)][string]$errorMessage = ($msgs.error_bad_command -f $cmd)
21 | )
22 | & $cmd
23 | if ($lastexitcode -ne 0) {
24 | throw ("Exec: " + $errorMessage)
25 | }
26 | }
27 |
28 | $outputDir = "../../output";
29 | Push-Location src\SimpleEventStore
30 |
31 | Write-Stage "Building solution"
32 | Exec { dotnet build -c $Configuration }
33 |
34 | Write-Stage "Running tests"
35 | $env:Uri = $Uri
36 | $env:AuthKey = $AuthKey
37 | $env:ConsistencyLevel = $ConsistencyLevel
38 |
39 | Exec { dotnet test SimpleEventStore.Tests -c $Configuration --no-build --logger trx }
40 | Exec { dotnet test SimpleEventStore.CosmosDb.Tests -c $Configuration --no-build --logger trx }
41 |
42 | Pop-Location
--------------------------------------------------------------------------------
/src/SimpleEventStore/Directory.Build.props:
--------------------------------------------------------------------------------
1 |
2 |
3 | 8
4 |
5 |
6 |
--------------------------------------------------------------------------------
/src/SimpleEventStore/SimpleEventStore.CosmosDb.Tests/AzureCosmosDbEventStoreAppending.cs:
--------------------------------------------------------------------------------
1 | using System.Threading.Tasks;
2 | using NUnit.Framework;
3 | using SimpleEventStore.Tests;
4 |
5 | namespace SimpleEventStore.CosmosDb.Tests
6 | {
7 | [TestFixture]
8 | public class AzureCosmosDbEventStoreAppending : EventStoreAppending
9 | {
10 | protected override Task CreateStorageEngine()
11 | {
12 | return CosmosDbStorageEngineFactory.Create("AppendingTests");
13 | }
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/src/SimpleEventStore/SimpleEventStore.CosmosDb.Tests/AzureCosmosDbEventStoreAppendingWithConverters.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.Generic;
3 | using System.Linq;
4 | using System.Threading.Tasks;
5 | using Newtonsoft.Json;
6 | using Newtonsoft.Json.Converters;
7 | using NUnit.Framework;
8 | using SimpleEventStore.Tests;
9 | using SimpleEventStore.Tests.Events;
10 |
11 | namespace SimpleEventStore.CosmosDb.Tests
12 | {
13 | [TestFixture]
14 | public class AzureCosmosDbEventStoreAppendingWithConverters : EventStoreTestBase
15 | {
16 | [Test]
17 | public async Task when_appending_an_event_that_requires_a_converter_the_event_is_saved_and_read()
18 | {
19 | var streamId = Guid.NewGuid().ToString();
20 | var @event = new EventData(Guid.NewGuid(), new OrderProcessed(streamId, new Version(1, 2, 0)));
21 |
22 | await Subject.AppendToStream(streamId, 0, @event);
23 |
24 | var stream = await Subject.ReadStreamForwards(streamId);
25 | Assert.That(stream.Count, Is.EqualTo(1));
26 | Assert.That(stream.Single().StreamId, Is.EqualTo(streamId));
27 | Assert.That(stream.Single().EventId, Is.EqualTo(@event.EventId));
28 | Assert.That(stream.Single().EventNumber, Is.EqualTo(1));
29 | }
30 |
31 | protected override Task CreateStorageEngine()
32 | {
33 | return CosmosDbStorageEngineFactory.Create("JsonSerializationSettingsTests",
34 | settings: new JsonSerializerSettings
35 | {
36 | Converters = new List
37 | {
38 | new VersionConverter()
39 | }
40 | });
41 | }
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/src/SimpleEventStore/SimpleEventStore.CosmosDb.Tests/AzureCosmosDbEventStoreInitializing.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Threading.Tasks;
3 | using Microsoft.Azure.Cosmos;
4 | using NUnit.Framework;
5 |
6 | namespace SimpleEventStore.CosmosDb.Tests
7 | {
8 | [TestFixture]
9 | public class AzureCosmosV3EventStoreInitializing
10 | {
11 | private const string DatabaseName = "EventStoreTests-Initialize-CosmosV3";
12 | private readonly CosmosClient client = CosmosClientFactory.Create();
13 |
14 | [TearDown]
15 | public Task TearDownDatabase()
16 | {
17 | return client.GetDatabase(DatabaseName).DeleteAsync();
18 | }
19 |
20 | [Test]
21 | public async Task when_initializing_all_expected_resources_are_created()
22 | {
23 | var collectionName = "AllExpectedResourcesAreCreated_" + Guid.NewGuid();
24 | await InitialiseStorageEngine(collectionName, collectionThroughput: TestConstants.RequestUnits);
25 |
26 | var database = client.GetDatabase(DatabaseName);
27 | var collection = database.GetContainer(collectionName);
28 |
29 | var collectionResponse = await collection.ReadContainerAsync();
30 | var collectionProperties = collectionResponse.Resource;
31 |
32 | var offer = await collection.ReadThroughputAsync();
33 |
34 | Assert.That(offer, Is.EqualTo(TestConstants.RequestUnits));
35 | Assert.That(collectionProperties.DefaultTimeToLive, Is.Null);
36 | Assert.That(collectionProperties.PartitionKeyPath, Is.EqualTo("/streamId"));
37 | Assert.That(collectionProperties.IndexingPolicy.IncludedPaths.Count, Is.EqualTo(1));
38 | Assert.That(collectionProperties.IndexingPolicy.IncludedPaths[0].Path, Is.EqualTo("/*"));
39 | Assert.That(collectionProperties.IndexingPolicy.ExcludedPaths.Count, Is.EqualTo(3));
40 | Assert.That(collectionProperties.IndexingPolicy.ExcludedPaths[0].Path, Is.EqualTo("/body/*"));
41 | Assert.That(collectionProperties.IndexingPolicy.ExcludedPaths[1].Path, Is.EqualTo("/metadata/*"));
42 | }
43 |
44 | [Test]
45 | public async Task when_using_shared_throughput_it_is_set_at_a_database_level()
46 | {
47 | const int dbThroughput = 800;
48 | var collectionName = "SharedCollection_" + Guid.NewGuid();
49 |
50 | await InitialiseStorageEngine(collectionName, dbThroughput: dbThroughput);
51 |
52 | Assert.That(dbThroughput, Is.EqualTo(await GetDatabaseThroughput()));
53 | Assert.That(await GetCollectionThroughput(collectionName), Is.Null);
54 | }
55 |
56 | [TestCase(60)]
57 | [TestCase(10)]
58 | [TestCase(90)]
59 | public async Task when_initializing_with_a_time_to_live_it_is_set(int ttl)
60 | {
61 | var collectionName = "TimeToLiveIsSet_" + Guid.NewGuid();
62 | var storageEngine = await CosmosDbStorageEngineFactory.Create(collectionName, DatabaseName,
63 | x =>
64 | {
65 | x.UseCollection(o => o.DefaultTimeToLive = ttl);
66 | });
67 |
68 | var collection = await client.GetContainer(DatabaseName, collectionName).ReadContainerAsync();
69 |
70 | var collectionProperties = collection.Resource;
71 |
72 | Assert.That(collectionProperties.DefaultTimeToLive, Is.EqualTo(ttl));
73 | }
74 |
75 |
76 | [Test]
77 | public async Task when_throughput_is_set_offer_is_updated()
78 | {
79 | var dbThroughput = 800;
80 | var collectionThroughput = 400;
81 | var collectionName = "UpdateThroughput_" + Guid.NewGuid();
82 |
83 | await InitialiseStorageEngine(collectionName, collectionThroughput, dbThroughput);
84 |
85 | Assert.That(dbThroughput, Is.EqualTo(await GetDatabaseThroughput()));
86 | Assert.That(collectionThroughput, Is.EqualTo(await GetCollectionThroughput(collectionName)));
87 |
88 | dbThroughput = 1600;
89 | collectionThroughput = 800;
90 |
91 | await InitialiseStorageEngine(collectionName, collectionThroughput, dbThroughput);
92 |
93 | Assert.That(dbThroughput, Is.EqualTo(await GetDatabaseThroughput()));
94 | Assert.That(collectionThroughput, Is.EqualTo(await GetCollectionThroughput(collectionName)));
95 | }
96 |
97 | [TestCase(null, null, null, 400)]
98 | [TestCase(600, null, 600, null)]
99 | [TestCase(null, 600, null, 600)]
100 | [TestCase(600, 600, 600, 600)]
101 | [TestCase(600, 1000, 600, 1000)]
102 | [TestCase(1000, 600, 1000, 600)]
103 | public async Task set_database_and_collection_throughput_when_database_has_not_been_created(int? dbThroughput, int? collectionThroughput, int? expectedDbThroughput, int? expectedCollectionThroughput)
104 | {
105 | var collectionName = "CollectionThroughput_" + Guid.NewGuid();
106 |
107 | await InitialiseStorageEngine(collectionName, collectionThroughput, dbThroughput);
108 |
109 | Assert.That(expectedDbThroughput, Is.EqualTo(await GetDatabaseThroughput()));
110 | Assert.That(expectedCollectionThroughput, Is.EqualTo(await GetCollectionThroughput(collectionName)));
111 | }
112 |
113 |
114 | [TestCase(null, 500, null)]
115 | [TestCase(1000, 500, 1000)]
116 | public async Task set_database_and_collection_throughput_when_database_has_already_been_created(int? collectionThroughput, int? expectedDbThroughput, int? expectedCollectionThroughput)
117 | {
118 | const int existingDbThroughput = 500;
119 | await CreateDatabase(existingDbThroughput);
120 | var collectionName = "CollectionThroughput_" + Guid.NewGuid();
121 |
122 | await InitialiseStorageEngine(collectionName, collectionThroughput, null);
123 |
124 | Assert.That(expectedDbThroughput, Is.EqualTo(await GetDatabaseThroughput()));
125 | Assert.That(expectedCollectionThroughput, Is.EqualTo(await GetCollectionThroughput(collectionName)));
126 | }
127 |
128 | private static async Task InitialiseStorageEngine(string collectionName, int? collectionThroughput = null,
129 | int? dbThroughput = null)
130 | {
131 | var storageEngine = await CosmosDbStorageEngineFactory.Create(collectionName, DatabaseName, x => {
132 | x.UseCollection(o => o.CollectionRequestUnits = collectionThroughput);
133 | x.UseDatabase(o => o.DatabaseRequestUnits = dbThroughput);
134 | });
135 |
136 | return await storageEngine.Initialise();
137 | }
138 |
139 | public Task GetCollectionThroughput(string collectionName)
140 | {
141 | var collection = client.GetContainer(DatabaseName, collectionName);
142 | return collection.ReadThroughputAsync();
143 | }
144 |
145 | public Task GetDatabaseThroughput()
146 | {
147 | return client.GetDatabase(DatabaseName).ReadThroughputAsync();
148 | }
149 |
150 | private Task CreateDatabase(int databaseRequestUnits)
151 | {
152 | return client.CreateDatabaseIfNotExistsAsync(DatabaseName, databaseRequestUnits);
153 | }
154 |
155 | }
156 | }
--------------------------------------------------------------------------------
/src/SimpleEventStore/SimpleEventStore.CosmosDb.Tests/AzureCosmosDbEventStoreLogging.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Threading.Tasks;
3 | using NUnit.Framework;
4 | using SimpleEventStore.Tests.Events;
5 |
6 | namespace SimpleEventStore.CosmosDb.Tests
7 | {
8 | [TestFixture]
9 | public class AzureCosmosDbEventStoreLogging
10 | {
11 | [Test]
12 | public async Task when_a_write_operation_is_successful_the_log_callback_is_called()
13 | {
14 | ResponseInformation response = null;
15 | var sut = new EventStore(await CreateStorageEngine(t => response = t));
16 | var streamId = Guid.NewGuid().ToString();
17 |
18 | await sut.AppendToStream(streamId, 0, new EventData(Guid.NewGuid(), new OrderCreated("TEST-ORDER")));
19 |
20 | Assert.That(response, Is.Not.Null);
21 | await TestContext.Out.WriteLineAsync($"Charge: {response.RequestCharge}");
22 | await TestContext.Out.WriteLineAsync($"Quota Usage: {response.CurrentResourceQuotaUsage}");
23 | await TestContext.Out.WriteLineAsync($"Max Resource Quote: {response.MaxResourceQuota}");
24 | await TestContext.Out.WriteLineAsync($"Response headers: {response.ResponseHeaders}");
25 | }
26 |
27 | [Test]
28 | public async Task when_a_read_operation_is_successful_the_log_callback_is_called()
29 | {
30 | var logCount = 0;
31 | var sut = new EventStore(await CreateStorageEngine(t => logCount++));
32 | var streamId = Guid.NewGuid().ToString();
33 |
34 | await sut.AppendToStream(streamId, 0, new EventData(Guid.NewGuid(), new OrderCreated("TEST-ORDER")));
35 | await sut.ReadStreamForwards(streamId);
36 |
37 | Assert.That(logCount, Is.EqualTo(2));
38 | }
39 |
40 | private static Task CreateStorageEngine(Action onSuccessCallback, string collectionName = "LoggingTests")
41 | {
42 | return CosmosDbStorageEngineFactory.Create("LoggingTests", builderOverrides: x => x.UseLogging(o => o.Success = onSuccessCallback));
43 | }
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/src/SimpleEventStore/SimpleEventStore.CosmosDb.Tests/AzureCosmosDbEventStoreReading.cs:
--------------------------------------------------------------------------------
1 | using System.Threading.Tasks;
2 | using NUnit.Framework;
3 | using SimpleEventStore.Tests;
4 |
5 | namespace SimpleEventStore.CosmosDb.Tests
6 | {
7 | [TestFixture]
8 | public class AzureCosmosDbEventStoreReading : EventStoreReading
9 | {
10 | protected override Task CreateStorageEngine()
11 | {
12 | return CosmosDbStorageEngineFactory.Create("ReadingTests");
13 | }
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/src/SimpleEventStore/SimpleEventStore.CosmosDb.Tests/AzureCosmosDbEventStoreReadingPartiallyDeletedStreams.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Linq;
3 | using System.Threading.Tasks;
4 | using Microsoft.Azure.Cosmos;
5 | using NUnit.Framework;
6 | using SimpleEventStore.Tests.Events;
7 |
8 | namespace SimpleEventStore.CosmosDb.Tests
9 | {
10 | [TestFixture]
11 | public class AzureCosmosDbEventStoreReadingPartiallyDeletedStreams
12 | {
13 | [Test]
14 | public async Task when_reading_a_stream_that_has_deleted_events_the_stream_can_still_be_read()
15 | {
16 | const string collectionName = "ReadingPartialStreamTests";
17 |
18 | var client = CosmosClientFactory.Create();
19 | var storageEngine = await CosmosDbStorageEngineFactory.Create(collectionName);
20 | var eventStore = new EventStore(storageEngine);
21 | var streamId = Guid.NewGuid().ToString();
22 | await eventStore.AppendToStream(streamId, 0, new EventData(Guid.NewGuid(), new OrderCreated(streamId)), new EventData(Guid.NewGuid(), new OrderDispatched(streamId)));
23 | await SimulateTimeToLiveExpiration(CosmosDbStorageEngineFactory.DefaultDatabaseName, collectionName, client, streamId);
24 |
25 | var stream = await eventStore.ReadStreamForwards(streamId);
26 |
27 | Assert.That(stream.Count, Is.EqualTo(1));
28 | Assert.That(stream.First().EventBody, Is.InstanceOf());
29 | Assert.That(stream.First().EventNumber, Is.EqualTo(2));
30 | }
31 |
32 | private static Task SimulateTimeToLiveExpiration(string databaseName, string collectionName, CosmosClient client, string streamId)
33 | {
34 | var collection = client.GetContainer(databaseName, collectionName);
35 | return collection.DeleteItemAsync(
36 | $"{streamId}:1",
37 | new PartitionKey(streamId));
38 | }
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/src/SimpleEventStore/SimpleEventStore.CosmosDb.Tests/AzureCosmosDbStorageEngineBuilderTests.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using Microsoft.Azure.Cosmos;
3 | using NUnit.Framework;
4 |
5 | namespace SimpleEventStore.CosmosDb.Tests
6 | {
7 | [TestFixture]
8 | public class AzureCosmosDbStorageEngineBuilderTests
9 | {
10 | [Test]
11 | public void when_creating_an_instance_the_document_client_must_be_supplied()
12 | {
13 | Assert.Throws(() => new AzureCosmosDbStorageEngineBuilder(null, "Test"));
14 | }
15 |
16 | [Test]
17 | public void when_creating_an_instance_the_database_name_must_be_supplied()
18 | {
19 | Assert.Throws(() => new AzureCosmosDbStorageEngineBuilder(CreateClient(), null));
20 | }
21 |
22 | [Test]
23 | public void when_setting_collection_settings_a_callback_must_be_supplied()
24 | {
25 | var builder = new AzureCosmosDbStorageEngineBuilder(CreateClient(), "Test");
26 | Assert.Throws(() => builder.UseCollection(null));
27 | }
28 |
29 | [Test]
30 | public void when_setting_subscription_settings_a_callback_must_be_supplied()
31 | {
32 | var builder = new AzureCosmosDbStorageEngineBuilder(CreateClient(), "Test");
33 | Assert.Throws(() => builder.UseCollection(null));
34 | }
35 |
36 | [Test]
37 | public void when_setting_logging_settings_a_callback_must_be_supplied()
38 | {
39 | var builder = new AzureCosmosDbStorageEngineBuilder(CreateClient(), "Test");
40 | Assert.Throws(() => builder.UseLogging(null));
41 | }
42 |
43 | [Test]
44 | public void when_setting_the_type_map_it_must_be_supplied()
45 | {
46 | var builder = new AzureCosmosDbStorageEngineBuilder(CreateClient(), "Test");
47 | Assert.Throws(() => builder.UseTypeMap(null));
48 | }
49 |
50 | [Test]
51 | public void when_setting_the_jsonserializationsettings_it_must_be_supplied()
52 | {
53 | var builder = new AzureCosmosDbStorageEngineBuilder(CreateClient(), "Test");
54 | Assert.Throws(() => builder.UseJsonSerializerSettings(null));
55 | }
56 |
57 | private static CosmosClient CreateClient()
58 | {
59 | return new CosmosClient("https://localhost:8081/", "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==");
60 | }
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/src/SimpleEventStore/SimpleEventStore.CosmosDb.Tests/ConfigurableTypeMapSerializationBinderTests.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Reflection;
3 | using NUnit.Framework;
4 | using SimpleEventStore.Tests.Events;
5 |
6 | namespace SimpleEventStore.CosmosDb.Tests
7 | {
8 | [TestFixture]
9 | public class ConfigurableTypeMapSerializationBinderTests
10 | {
11 | [Test]
12 | public void when_registering_a_type_with_a_null_event_type_then_an_exception_is_thrown()
13 | {
14 | var sut = new ConfigurableSerializationTypeMap();
15 | Assert.Throws(() => sut.RegisterType(null, typeof(OrderCreated)));
16 | }
17 |
18 | [Test]
19 | public void when_registering_a_type_with_a_null_type_then_an_exception_is_thrown()
20 | {
21 | var sut = new ConfigurableSerializationTypeMap();
22 | Assert.Throws(() => sut.RegisterType("TEST", null));
23 | }
24 |
25 | [Test]
26 | public void when_registering_types_with_a_null_assembly_then_an_exception_is_thrown()
27 | {
28 | var sut = new ConfigurableSerializationTypeMap();
29 | Assert.Throws(() => sut.RegisterTypes(null, t => true, t => t.Name));
30 | }
31 |
32 | [Test]
33 | public void when_registering_events_with_a_null_match_function_then_an_exception_is_thrown()
34 | {
35 | var sut = new ConfigurableSerializationTypeMap();
36 | Assert.Throws(() => sut.RegisterTypes(typeof(OrderCreated).GetTypeInfo().Assembly, null, t => t.Name));
37 | }
38 |
39 | [Test]
40 | public void when_registering_types_with_a_null_naming_function_then_an_exception_is_thrown()
41 | {
42 | var sut = new ConfigurableSerializationTypeMap();
43 | Assert.Throws(() => sut.RegisterTypes(typeof(OrderCreated).GetTypeInfo().Assembly, t => true, null));
44 | }
45 |
46 | [Test]
47 | public void when_registering_a_type_then_the_type_can_be_found()
48 | {
49 | var sut = new ConfigurableSerializationTypeMap();
50 | sut.RegisterType("OrderCreated", typeof(OrderCreated));
51 |
52 | Assert.That(sut.GetTypeFromName("OrderCreated"), Is.EqualTo(typeof(OrderCreated)));
53 | }
54 |
55 | [Test]
56 | public void when_registering_multiple_types_then_the_type_can_be_found()
57 | {
58 | var sut = new ConfigurableSerializationTypeMap();
59 | sut.RegisterTypes(typeof(OrderCreated).GetTypeInfo().Assembly, t => t.Namespace != null && t.Namespace.EndsWith("Events"), t => t.Name);
60 |
61 | Assert.That(sut.GetTypeFromName("OrderCreated"), Is.EqualTo(typeof(OrderCreated)));
62 | }
63 |
64 | [Test]
65 | public void when_registering_a_type_then_the_name_can_be_found()
66 | {
67 | var sut = new ConfigurableSerializationTypeMap();
68 | sut.RegisterType("OrderCreated", typeof(OrderCreated));
69 |
70 | Assert.That(sut.GetNameFromType(typeof(OrderCreated)), Is.EqualTo("OrderCreated"));
71 | }
72 |
73 | [Test]
74 | public void when_registering_multiple_types_if_no_types_are_found_then_an_exception_is_thrown()
75 | {
76 | var sut = new ConfigurableSerializationTypeMap();
77 | Assert.Throws(() => sut.RegisterTypes(typeof(OrderCreated).GetTypeInfo().Assembly, t => false, t => t.Name));
78 | }
79 | }
80 | }
81 |
--------------------------------------------------------------------------------
/src/SimpleEventStore/SimpleEventStore.CosmosDb.Tests/CosmosClientFactory.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.Azure.Cosmos;
2 | using Microsoft.Azure.Cosmos.Fluent;
3 | using Microsoft.Extensions.Configuration;
4 | using Newtonsoft.Json;
5 |
6 | namespace SimpleEventStore.CosmosDb.Tests
7 | {
8 | internal static class CosmosClientFactory
9 | {
10 | internal static CosmosClient Create()
11 | {
12 | return Create(new JsonSerializerSettings());
13 | }
14 |
15 | internal static CosmosClient Create(JsonSerializerSettings serializationOptions)
16 | {
17 | var config = new ConfigurationBuilder()
18 | .AddJsonFile("appsettings.json")
19 | .AddEnvironmentVariables()
20 | .Build();
21 |
22 | var documentDbUri = config["Uri"];
23 | var authKey = config["AuthKey"];
24 |
25 | return new CosmosClientBuilder(documentDbUri, authKey)
26 | .WithCustomSerializer(new CosmosJsonNetSerializer(serializationOptions))
27 | .Build();
28 | }
29 | }
30 | }
--------------------------------------------------------------------------------
/src/SimpleEventStore/SimpleEventStore.CosmosDb.Tests/CosmosDbStorageEngineFactory.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Reflection;
3 | using System.Threading.Tasks;
4 | using Microsoft.Azure.Cosmos;
5 | using Microsoft.Extensions.Configuration;
6 | using Newtonsoft.Json;
7 | using SimpleEventStore.Tests.Events;
8 |
9 | namespace SimpleEventStore.CosmosDb.Tests
10 | {
11 | internal static class CosmosDbStorageEngineFactory
12 | {
13 | public const string DefaultDatabaseName = "EventStoreTests";
14 |
15 | internal static Task Create(string collectionName, string databaseName = null, Action builderOverrides = null, JsonSerializerSettings settings = null)
16 | {
17 | settings = settings ?? new JsonSerializerSettings();
18 |
19 | databaseName = databaseName ?? DefaultDatabaseName;
20 |
21 | var config = new ConfigurationBuilder()
22 | .AddJsonFile("appsettings.json")
23 | .AddEnvironmentVariables()
24 | .Build();
25 |
26 | var consistencyLevel = config["ConsistencyLevel"];
27 |
28 | if (!Enum.TryParse(consistencyLevel, true, out ConsistencyLevel consistencyLevelEnum))
29 | {
30 | throw new Exception($"The ConsistencyLevel value {consistencyLevel} is not supported");
31 | }
32 |
33 | var client = CosmosClientFactory.Create(settings);
34 |
35 | var builder = new AzureCosmosDbStorageEngineBuilder(client, databaseName)
36 | .UseDatabase(o =>
37 | {
38 | o.DatabaseRequestUnits = TestConstants.RequestUnits;
39 | })
40 | .UseCollection(o =>
41 | {
42 | o.CollectionName = collectionName;
43 | o.ConsistencyLevel = consistencyLevelEnum;
44 | o.CollectionRequestUnits = null;
45 | })
46 | .UseTypeMap(new ConfigurableSerializationTypeMap()
47 | .RegisterTypes(
48 | typeof(OrderCreated).GetTypeInfo().Assembly,
49 | t => t.Namespace != null && t.Namespace.EndsWith("Events"),
50 | t => t.Name))
51 | .UseJsonSerializerSettings(settings);
52 |
53 | builderOverrides?.Invoke(builder);
54 |
55 | return builder.Build().Initialise();
56 | }
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/src/SimpleEventStore/SimpleEventStore.CosmosDb.Tests/CosmosDbStorageEventTests.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Reflection;
3 | using Newtonsoft.Json;
4 | using Newtonsoft.Json.Linq;
5 | using SimpleEventStore.Tests.Events;
6 | using NUnit.Framework;
7 |
8 | namespace SimpleEventStore.CosmosDb.Tests
9 | {
10 | [TestFixture]
11 | public class DocumentDbStorageEventTests
12 | {
13 | [Test]
14 | public void when_converting_to_a_storage_event_it_succeeds()
15 | {
16 | var id = Guid.NewGuid();
17 | var body = new OrderCreated("TEST-ORDER");
18 | var metadata = new TestMetadata { Value = "TEST-VALUE" };
19 | var sut = new CosmosDbStorageEvent
20 | {
21 | StreamId = "TEST-STREAM",
22 | Body = JObject.FromObject(body),
23 | BodyType = "OrderCreated",
24 | Metadata = JObject.FromObject(metadata),
25 | MetadataType = "TestMetadata",
26 | EventNumber = 1,
27 | EventId = id
28 | };
29 | var typeMap = new ConfigurableSerializationTypeMap().RegisterTypes(
30 | typeof(OrderCreated).GetTypeInfo().Assembly,
31 | t => t.Namespace != null && t.Namespace.EndsWith("Events"),
32 | t => t.Name);
33 | var result = sut.ToStorageEvent(typeMap, JsonSerializer.CreateDefault());
34 |
35 | Assert.That(result.StreamId, Is.EqualTo(sut.StreamId));
36 | Assert.That(((OrderCreated)result.EventBody).OrderId, Is.EqualTo(body.OrderId));
37 | Assert.That(((TestMetadata)result.Metadata).Value, Is.EqualTo(metadata.Value));
38 | Assert.That(result.EventNumber, Is.EqualTo(sut.EventNumber));
39 | Assert.That(result.EventId, Is.EqualTo(sut.EventId));
40 | }
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/src/SimpleEventStore/SimpleEventStore.CosmosDb.Tests/PerformanceTests.cs:
--------------------------------------------------------------------------------
1 | namespace SimpleEventStore.CosmosDb.Tests
2 | {
3 | using System;
4 | using System.Collections.Concurrent;
5 | using System.Collections.Generic;
6 | using System.Diagnostics;
7 | using System.Linq;
8 | using System.Threading.Tasks;
9 | using Microsoft.Azure.Cosmos;
10 | using NUnit.Framework;
11 | using SimpleEventStore.Tests;
12 | using SimpleEventStore.Tests.Events;
13 |
14 | [Ignore("Not used in CI/CD pipeline")]
15 | public class PerformanceTests : EventStoreTestBase
16 | {
17 | private const string DatabaseName = "PerformanceTests";
18 | private readonly CosmosClient client = CosmosClientFactory.Create();
19 |
20 | [OneTimeTearDown]
21 | public async Task TearDownDatabase()
22 | {
23 | await client.GetDatabase(DatabaseName).DeleteAsync();
24 | }
25 |
26 | protected override Task CreateStorageEngine()
27 | {
28 | return CosmosDbStorageEngineFactory.Create(collectionName: "PerformanceTests", databaseName: DatabaseName, builder => builder.UseDatabase(options => options.DatabaseRequestUnits = 100000));
29 | }
30 |
31 | private static readonly IEnumerable NumberOfClients = Enumerable.Range(10, 100).Where(i => i % 25 == 0);
32 |
33 | [Test]
34 | public async Task append_as_quickly_as_possible([ValueSource(nameof(NumberOfClients))] int clients)
35 | {
36 | const int streamsPerClient = 40;
37 | const int eventsPerStream = 2;
38 |
39 | var streams = new ConcurrentBag();
40 |
41 | async Task CreateStreams()
42 | {
43 | for (var i = 0; i < streamsPerClient; i++)
44 | {
45 | var streamId = Guid.NewGuid().ToString();
46 |
47 | await this.Subject.AppendToStream(streamId, 0, new EventData(Guid.NewGuid(), new OrderCreated(streamId)));
48 | await this.Subject.AppendToStream(streamId, 1, new EventData(Guid.NewGuid(), new OrderDispatched(streamId)));
49 |
50 | streams.Add(streamId);
51 | }
52 | }
53 |
54 | var streamCreatingClients = new List();
55 | for (var i = 0; i < clients; i++)
56 | {
57 | streamCreatingClients.Add(CreateStreams());
58 | }
59 |
60 | var stopWatch = Stopwatch.StartNew();
61 | await Task.WhenAll(streamCreatingClients);
62 | stopWatch.Stop();
63 | var timeTaken = stopWatch.Elapsed;
64 |
65 | var writesPerSecond = (clients * streamsPerClient * eventsPerStream) / timeTaken.TotalSeconds;
66 |
67 | Console.WriteLine($"Clients = {clients}");
68 | Console.WriteLine($"Streams per client = {streamsPerClient}");
69 | Console.WriteLine($"Total events written = {clients * streamsPerClient * eventsPerStream}");
70 | Console.WriteLine($"Writes per second = {writesPerSecond}");
71 |
72 | Assert.That(streams.Count, Is.EqualTo(clients * streamsPerClient));
73 | }
74 | }
75 | }
--------------------------------------------------------------------------------
/src/SimpleEventStore/SimpleEventStore.CosmosDb.Tests/ResponseInformationBuilding.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.Generic;
3 | using System.Collections.Specialized;
4 | using System.Net;
5 | using Microsoft.Azure.Cosmos;
6 | using Microsoft.Azure.Cosmos.Scripts;
7 | using NUnit.Framework;
8 |
9 | namespace SimpleEventStore.CosmosDb.Tests
10 | {
11 | [TestFixture]
12 | public class ResponseInformationBuilding
13 | {
14 | [Test]
15 | public void when_building_from_a_write_response_all_target_fields_are_mapped()
16 | {
17 | var result = ResponseInformation.FromWriteResponse(Expected.RequestIdentifier, new FakeStoredProcedureResponse());
18 |
19 | Assert.That(result.RequestIdentifier, Is.EqualTo(Expected.RequestIdentifier));
20 | Assert.That(result.RequestCharge, Is.EqualTo(Expected.RequestCharge));
21 | Assert.That(result.ResponseHeaders, Is.EqualTo(Expected.ResponseHeaders));
22 | }
23 |
24 | [Test]
25 | public void when_building_from_a_read_response_all_target_fields_are_mapped()
26 | {
27 | var result = ResponseInformation.FromReadResponse(Expected.RequestIdentifier, new FakeFeedResponse());
28 |
29 | Assert.That(result.RequestIdentifier, Is.EqualTo(Expected.RequestIdentifier));
30 | Assert.That(result.CurrentResourceQuotaUsage, Is.EqualTo(Expected.CurrentResourceQuotaUsage));
31 | Assert.That(result.MaxResourceQuota, Is.EqualTo(Expected.MaxResourceQuota));
32 | Assert.That(result.RequestCharge, Is.EqualTo(Expected.RequestCharge));
33 | Assert.That(result.ResponseHeaders, Is.EqualTo(Expected.ResponseHeaders));
34 | }
35 |
36 | [Test]
37 | public void when_building_from_a_subscription_read_response_all_target_fields_are_mapped()
38 | {
39 | var result = ResponseInformation.FromSubscriptionReadResponse(Expected.RequestIdentifier, new FakeFeedResponse());
40 |
41 | Assert.That(result.RequestIdentifier, Is.EqualTo(Expected.RequestIdentifier));
42 | Assert.That(result.CurrentResourceQuotaUsage, Is.EqualTo(Expected.CurrentResourceQuotaUsage));
43 | Assert.That(result.MaxResourceQuota, Is.EqualTo(Expected.MaxResourceQuota));
44 | Assert.That(result.RequestCharge, Is.EqualTo(Expected.RequestCharge));
45 | Assert.That(result.ResponseHeaders, Is.EqualTo(Expected.ResponseHeaders));
46 | }
47 |
48 | private static class Expected
49 | {
50 | internal const string RequestIdentifier = "TEST-Identifier";
51 | internal const string CurrentResourceQuotaUsage = "TEST-CurrentResourceQuotaUsage";
52 | internal const string MaxResourceQuota = "TEST-MaxResourceQuota";
53 | internal const double RequestCharge = 100d;
54 | internal static NameValueCollection ResponseHeaders = new NameValueCollection();
55 |
56 | static Expected()
57 | {
58 | ResponseHeaders.Add("Location", "");
59 | ResponseHeaders.Add("Session", "");
60 | ResponseHeaders.Add("RequestCharge", "0");
61 | ResponseHeaders.Add("ActivityId", "");
62 | ResponseHeaders.Add("ContentLength", "");
63 | ResponseHeaders.Add("ContentType", "");
64 | ResponseHeaders.Add("ContinuationToken", "");
65 | ResponseHeaders.Add("ETag", "");
66 | }
67 | }
68 |
69 | private class FakeStoredProcedureResponse : StoredProcedureExecuteResponse
70 | {
71 | internal FakeStoredProcedureResponse()
72 | {
73 | RequestCharge = Expected.RequestCharge;
74 | ResponseHeaders = Expected.ResponseHeaders;
75 | }
76 |
77 | public override string ActivityId { get; }
78 |
79 | public override double RequestCharge { get; }
80 |
81 | public TValue Response { get; }
82 |
83 | public NameValueCollection ResponseHeaders { get; }
84 |
85 | public override string SessionToken { get; }
86 |
87 | public override string ScriptLog { get; }
88 |
89 | public override HttpStatusCode StatusCode { get; }
90 | }
91 |
92 | private class FakeFeedResponse : FeedResponse
93 | {
94 | internal FakeFeedResponse()
95 | {
96 | RequestCharge = Expected.RequestCharge;
97 | ResponseHeaders = Expected.ResponseHeaders;
98 | Headers = new Headers
99 | {
100 | {"x-ms-resource-quota", Expected.MaxResourceQuota},
101 | {"x-ms-resource-usage", Expected.CurrentResourceQuotaUsage}
102 | };
103 | }
104 |
105 | public long DatabaseQuota { get; }
106 |
107 | public long DatabaseUsage { get; }
108 |
109 | public long CollectionQuota { get; }
110 |
111 | public long CollectionUsage { get; }
112 |
113 | public long UserQuota { get; }
114 |
115 | public long UserUsage { get; }
116 |
117 | public long PermissionQuota { get; }
118 |
119 | public long PermissionUsage { get; }
120 |
121 | public long CollectionSizeQuota { get; }
122 |
123 | public long CollectionSizeUsage { get; }
124 |
125 | public long StoredProceduresQuota { get; }
126 |
127 | public long StoredProceduresUsage { get; }
128 |
129 | public long TriggersQuota { get; }
130 |
131 | public long TriggersUsage { get; }
132 |
133 | public long UserDefinedFunctionsQuota { get; }
134 |
135 | public long UserDefinedFunctionsUsage { get; }
136 |
137 | public override string ContinuationToken { get; }
138 | public override int Count { get; }
139 |
140 | public override Headers Headers { get; }
141 | public override IEnumerable Resource { get; }
142 | public override HttpStatusCode StatusCode { get; }
143 | public override double RequestCharge { get; }
144 |
145 | public override string ActivityId { get; }
146 | public override CosmosDiagnostics Diagnostics { get; }
147 |
148 | public string ResponseContinuation { get; }
149 |
150 | public string SessionToken { get; }
151 |
152 | public string ContentLocation { get; }
153 |
154 | public NameValueCollection ResponseHeaders { get; }
155 |
156 | public override string IndexMetrics => string.Empty;
157 |
158 | public override IEnumerator GetEnumerator()
159 | {
160 | yield return Activator.CreateInstance();
161 | }
162 | }
163 | }
164 | }
165 |
--------------------------------------------------------------------------------
/src/SimpleEventStore/SimpleEventStore.CosmosDb.Tests/SimpleEventStore.CosmosDb.Tests.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | net8.0
5 | SimpleEventStore.CosmosDb.Tests
6 |
7 |
8 |
9 | Always
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 | SimpleEventStore.CosmosDb.Tests
27 | ASOS
28 | Copyright ASOS © 2024
29 | SimpleEventStore.CosmosDb.Tests
30 |
31 |
--------------------------------------------------------------------------------
/src/SimpleEventStore/SimpleEventStore.CosmosDb.Tests/TestConstants.cs:
--------------------------------------------------------------------------------
1 | namespace SimpleEventStore.CosmosDb.Tests
2 | {
3 | internal static class TestConstants
4 | {
5 | internal const int RequestUnits = 700;
6 | }
7 | }
--------------------------------------------------------------------------------
/src/SimpleEventStore/SimpleEventStore.CosmosDb.Tests/appsettings.json:
--------------------------------------------------------------------------------
1 | {
2 | "Uri": "https://localhost:8081/",
3 | "AuthKey": "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==",
4 | "ConsistencyLevel": "Session"
5 | }
--------------------------------------------------------------------------------
/src/SimpleEventStore/SimpleEventStore.CosmosDb/AzureCosmosDbStorageEngine.cs:
--------------------------------------------------------------------------------
1 | using System.Collections.Generic;
2 | using System.Linq;
3 | using System.Net;
4 | using System.Threading;
5 | using System.Threading.Tasks;
6 | using Microsoft.Azure.Cosmos;
7 | using Newtonsoft.Json;
8 |
9 | namespace SimpleEventStore.CosmosDb
10 | {
11 | internal class AzureCosmosDbStorageEngine : IStorageEngine
12 | {
13 | private readonly CosmosClient _client;
14 | private readonly string _databaseName;
15 | private readonly CollectionOptions collectionOptions;
16 | private readonly LoggingOptions _loggingOptions;
17 | private readonly ISerializationTypeMap _typeMap;
18 | private readonly JsonSerializer _jsonSerializer;
19 | private readonly DatabaseOptions _databaseOptions;
20 | private Database _database;
21 | private Container _collection;
22 |
23 | internal AzureCosmosDbStorageEngine(CosmosClient client, string databaseName,
24 | CollectionOptions collectionOptions, DatabaseOptions databaseOptions, LoggingOptions loggingOptions,
25 | ISerializationTypeMap typeMap, JsonSerializer serializer)
26 | {
27 | _client = client;
28 | _databaseName = databaseName;
29 | _databaseOptions = databaseOptions;
30 | this.collectionOptions = collectionOptions;
31 | _loggingOptions = loggingOptions;
32 | _typeMap = typeMap;
33 | _jsonSerializer = serializer;
34 | }
35 |
36 | public async Task Initialise(CancellationToken cancellationToken = default)
37 | {
38 | cancellationToken.ThrowIfCancellationRequested();
39 | var databaseResponse = await CreateDatabaseIfItDoesNotExist();
40 | _database = databaseResponse.Database;
41 |
42 | cancellationToken.ThrowIfCancellationRequested();
43 | var containerResponse = (await CreateCollectionIfItDoesNotExist());
44 | _collection = containerResponse.Container;
45 |
46 | cancellationToken.ThrowIfCancellationRequested();
47 | await Task.WhenAll(
48 | SetDatabaseOfferThroughput(),
49 | SetCollectionOfferThroughput()
50 | );
51 |
52 | return this;
53 | }
54 |
55 | public async Task AppendToStream(string streamId, IEnumerable events,
56 | CancellationToken cancellationToken = default)
57 | {
58 | var storageEvents = events.ToList();
59 | var firstEventNumber = storageEvents.First().EventNumber;
60 |
61 | try
62 | {
63 | var transactionalBatchItemRequestOptions = new TransactionalBatchItemRequestOptions
64 | {
65 | EnableContentResponseOnWrite = false
66 | };
67 |
68 | var batch = storageEvents.Aggregate(
69 | _collection.CreateTransactionalBatch(new PartitionKey(streamId)),
70 | (b, e) => b.CreateItem(CosmosDbStorageEvent.FromStorageEvent(e, _typeMap, _jsonSerializer), transactionalBatchItemRequestOptions));
71 |
72 | var batchResponse = firstEventNumber == 1 ?
73 | await CreateEvents(batch, cancellationToken) :
74 | await CreateEventsOnlyIfPreviousEventExists(batch, streamId, firstEventNumber - 1, cancellationToken);
75 |
76 | _loggingOptions.OnSuccess(ResponseInformation.FromWriteResponse(nameof(AppendToStream), batchResponse));
77 | }
78 | catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.Conflict|| ex.Headers["x-ms-substatus"] == "409" || ex.SubStatusCode == 409)
79 | {
80 | throw new ConcurrencyException(
81 | $"Concurrency conflict when appending to stream {streamId}. Expected revision {firstEventNumber - 1}",
82 | ex);
83 | }
84 | }
85 |
86 | private static async Task CreateEvents(
87 | TransactionalBatch batch, CancellationToken cancellationToken)
88 | {
89 | using var batchResponse = await batch.ExecuteAsync(cancellationToken);
90 |
91 | return batchResponse.IsSuccessStatusCode
92 | ? batchResponse
93 | : throw new CosmosException(batchResponse.ErrorMessage, batchResponse.StatusCode, 0,
94 | batchResponse.ActivityId, batchResponse.RequestCharge);
95 | }
96 |
97 | private static async Task CreateEventsOnlyIfPreviousEventExists(
98 | TransactionalBatch batch, string streamId, int previousEventNumber, CancellationToken cancellationToken)
99 | {
100 | batch.ReadItem(streamId + $":{previousEventNumber}", new TransactionalBatchItemRequestOptions { EnableContentResponseOnWrite = true });
101 | using var batchResponse = await batch.ExecuteAsync(cancellationToken);
102 |
103 | return batchResponse.IsSuccessStatusCode
104 | ? batchResponse
105 | : throw batchResponse.StatusCode switch
106 | {
107 | HttpStatusCode.NotFound => new CosmosException(
108 | $"Previous Event {previousEventNumber} not found for stream '{streamId}'",
109 | HttpStatusCode.Conflict, 0, batchResponse.ActivityId, batchResponse.RequestCharge),
110 | _ => new CosmosException(batchResponse.ErrorMessage, batchResponse.StatusCode, 0,
111 | batchResponse.ActivityId, batchResponse.RequestCharge)
112 | };
113 | }
114 |
115 | public async Task> ReadStreamForwards(string streamId, int startPosition,
116 | int numberOfEventsToRead, CancellationToken cancellationToken = default)
117 | {
118 | int endPosition = numberOfEventsToRead == int.MaxValue
119 | ? int.MaxValue
120 | : startPosition + numberOfEventsToRead - 1;
121 |
122 | var queryDefinition = new QueryDefinition(@"
123 | SELECT VALUE e
124 | FROM e
125 | WHERE e.streamId = @StreamId
126 | AND (e.eventNumber BETWEEN @LowerBound AND @UpperBound)
127 | ORDER BY e.eventNumber ASC"
128 | )
129 | .WithParameter("@StreamId", streamId)
130 | .WithParameter("@LowerBound", startPosition)
131 | .WithParameter("@UpperBound", endPosition);
132 |
133 | var options = new QueryRequestOptions
134 | {
135 | MaxItemCount = numberOfEventsToRead,
136 | PartitionKey = new PartitionKey(streamId)
137 | };
138 |
139 | using var eventsQuery = _collection.GetItemQueryIterator(queryDefinition, requestOptions: options);
140 | var events = new List();
141 |
142 | while (eventsQuery.HasMoreResults)
143 | {
144 | var response = await eventsQuery.ReadNextAsync(cancellationToken);
145 | _loggingOptions.OnSuccess(ResponseInformation.FromReadResponse(nameof(ReadStreamForwards), response));
146 |
147 | foreach (var e in response)
148 | {
149 | events.Add(e.ToStorageEvent(_typeMap, _jsonSerializer));
150 | }
151 | }
152 |
153 | return events.AsReadOnly();
154 | }
155 |
156 | private Task CreateDatabaseIfItDoesNotExist()
157 | {
158 | return _client.CreateDatabaseIfNotExistsAsync(_databaseName, _databaseOptions.DatabaseRequestUnits);
159 | }
160 |
161 | private Task CreateCollectionIfItDoesNotExist()
162 | {
163 | var collectionProperties = new ContainerProperties()
164 | {
165 | Id = collectionOptions.CollectionName,
166 | IndexingPolicy = new IndexingPolicy
167 | {
168 | IncludedPaths =
169 | {
170 | new IncludedPath {Path = "/*"},
171 | },
172 | ExcludedPaths =
173 | {
174 | new ExcludedPath {Path = "/body/*"},
175 | new ExcludedPath {Path = "/metadata/*"}
176 | }
177 | },
178 | DefaultTimeToLive = collectionOptions.DefaultTimeToLive,
179 | PartitionKeyPath = "/streamId"
180 | };
181 |
182 | return _database.CreateContainerIfNotExistsAsync(collectionProperties,
183 | collectionOptions.CollectionRequestUnits);
184 | }
185 |
186 | private async Task SetCollectionOfferThroughput()
187 | {
188 | if (collectionOptions.CollectionRequestUnits != null)
189 | {
190 | await _collection.ReplaceThroughputAsync((int) collectionOptions.CollectionRequestUnits);
191 | }
192 | }
193 |
194 | private async Task SetDatabaseOfferThroughput()
195 | {
196 | if (_databaseOptions.DatabaseRequestUnits != null)
197 | {
198 | await _database.ReplaceThroughputAsync((int) _databaseOptions.DatabaseRequestUnits);
199 | }
200 | }
201 | }
202 | }
--------------------------------------------------------------------------------
/src/SimpleEventStore/SimpleEventStore.CosmosDb/AzureCosmosDbStorageEngineBuilder.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using Microsoft.Azure.Cosmos;
3 | using Newtonsoft.Json;
4 |
5 | namespace SimpleEventStore.CosmosDb
6 | {
7 | public class AzureCosmosDbStorageEngineBuilder
8 | {
9 | private readonly string _databaseName;
10 | private readonly CosmosClient _client;
11 | private readonly CollectionOptions collectionOptions = new CollectionOptions();
12 | private readonly DatabaseOptions _databaseOptions = new DatabaseOptions();
13 | private readonly LoggingOptions _loggingOptions = new LoggingOptions();
14 | private ISerializationTypeMap _typeMap = new DefaultSerializationTypeMap();
15 | private JsonSerializerSettings _jsonSerializerSettings = new JsonSerializerSettings();
16 |
17 | public AzureCosmosDbStorageEngineBuilder(CosmosClient client, string databaseName)
18 | {
19 | Guard.IsNotNull(nameof(client), client);
20 | Guard.IsNotNullOrEmpty(nameof(databaseName), databaseName);
21 |
22 | _client = client;
23 | _databaseName = databaseName;
24 | }
25 |
26 | public AzureCosmosDbStorageEngineBuilder UseCollection(Action action)
27 | {
28 | Guard.IsNotNull(nameof(action), action);
29 |
30 | action(collectionOptions);
31 | return this;
32 | }
33 |
34 | public AzureCosmosDbStorageEngineBuilder UseLogging(Action action)
35 | {
36 | Guard.IsNotNull(nameof(action), action);
37 |
38 | action(_loggingOptions);
39 | return this;
40 | }
41 |
42 | public AzureCosmosDbStorageEngineBuilder UseTypeMap(ISerializationTypeMap typeMap)
43 | {
44 | Guard.IsNotNull(nameof(typeMap), typeMap);
45 | _typeMap = typeMap;
46 |
47 | return this;
48 | }
49 |
50 | public AzureCosmosDbStorageEngineBuilder UseJsonSerializerSettings(JsonSerializerSettings settings)
51 | {
52 | Guard.IsNotNull(nameof(settings), settings);
53 | _jsonSerializerSettings = settings;
54 | return this;
55 | }
56 |
57 | public AzureCosmosDbStorageEngineBuilder UseDatabase(Action action)
58 | {
59 | Guard.IsNotNull(nameof(action), action);
60 |
61 | action(_databaseOptions);
62 | return this;
63 | }
64 |
65 | public IStorageEngine Build()
66 | {
67 | return new AzureCosmosDbStorageEngine(_client,
68 | _databaseName,
69 | collectionOptions,
70 | _databaseOptions,
71 | _loggingOptions,
72 | _typeMap,
73 | JsonSerializer.Create(_jsonSerializerSettings));
74 | }
75 | }
76 | }
77 |
--------------------------------------------------------------------------------
/src/SimpleEventStore/SimpleEventStore.CosmosDb/CollectionOptions.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.Azure.Cosmos;
2 |
3 | namespace SimpleEventStore.CosmosDb
4 | {
5 | public class CollectionOptions
6 | {
7 | public CollectionOptions()
8 | {
9 | ConsistencyLevel = ConsistencyLevel.Session;
10 | CollectionRequestUnits = 400;
11 | CollectionName = "Commits";
12 | }
13 |
14 | public string CollectionName { get; set; }
15 |
16 | public ConsistencyLevel ConsistencyLevel { get; set; }
17 |
18 | public int? CollectionRequestUnits { get; set; }
19 |
20 | public int? DefaultTimeToLive { get; set; }
21 | }
22 | }
--------------------------------------------------------------------------------
/src/SimpleEventStore/SimpleEventStore.CosmosDb/ConfigurableSerializationTypeMap.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.Generic;
3 | using System.Linq;
4 | using System.Reflection;
5 |
6 | namespace SimpleEventStore.CosmosDb
7 | {
8 | public class ConfigurableSerializationTypeMap : ISerializationTypeMap
9 | {
10 | private readonly Dictionary typeMap = new Dictionary();
11 | private readonly Dictionary nameMap = new Dictionary();
12 |
13 | public ConfigurableSerializationTypeMap RegisterType(string eventType, Type type)
14 | {
15 | Guard.IsNotNullOrEmpty(nameof(eventType), eventType);
16 | Guard.IsNotNull(nameof(type), type);
17 |
18 | typeMap.Add(eventType, type);
19 | nameMap.Add(type, eventType);
20 | return this;
21 | }
22 |
23 | public ConfigurableSerializationTypeMap RegisterTypes(Assembly assembly, Func matchFunc, Func namingFunc)
24 | {
25 | Guard.IsNotNull(nameof(assembly), assembly);
26 | Guard.IsNotNull(nameof(matchFunc), matchFunc);
27 | Guard.IsNotNull(nameof(namingFunc), namingFunc);
28 | bool matchesFound = false;
29 |
30 | foreach (var type in assembly.GetTypes().Where(matchFunc))
31 | {
32 | matchesFound = true;
33 | RegisterType(namingFunc(type), type);
34 | }
35 |
36 | if (!matchesFound)
37 | {
38 | throw new NoTypesFoundException("The matchFunc matched no types in the assembly");
39 | }
40 |
41 | return this;
42 | }
43 |
44 | public Type GetTypeFromName(string typeName)
45 | {
46 | return typeMap[typeName];
47 | }
48 |
49 | public string GetNameFromType(Type type)
50 | {
51 | return nameMap[type];
52 | }
53 | }
54 |
55 | public class NoTypesFoundException : Exception
56 | {
57 | public NoTypesFoundException(string message) : base(message)
58 | { }
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/src/SimpleEventStore/SimpleEventStore.CosmosDb/CosmosDbStorageEvent.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using Microsoft.Azure.Cosmos;
3 | using Newtonsoft.Json;
4 | using Newtonsoft.Json.Linq;
5 |
6 | namespace SimpleEventStore.CosmosDb
7 | {
8 | public class CosmosDbStorageEvent
9 | {
10 | [JsonProperty("id")]
11 | public string Id { get; set; }
12 |
13 | [JsonProperty("eventId")]
14 | public Guid EventId { get; set; }
15 |
16 | [JsonProperty("body")]
17 | public JObject Body { get; set; }
18 |
19 | [JsonProperty("bodyType")]
20 | public string BodyType { get; set; }
21 |
22 | [JsonProperty("metadata")]
23 | public JObject Metadata { get; set; }
24 |
25 | [JsonProperty("metadataType")]
26 | public string MetadataType { get; set; }
27 |
28 | [JsonProperty("streamId")]
29 | public string StreamId { get; set; }
30 |
31 | [JsonProperty("eventNumber")]
32 | public int EventNumber { get; set; }
33 |
34 | public static CosmosDbStorageEvent FromStorageEvent(StorageEvent @event, ISerializationTypeMap typeMap, JsonSerializer serializer)
35 | {
36 | var docDbEvent = new CosmosDbStorageEvent();
37 | docDbEvent.Id = $"{@event.StreamId}:{@event.EventNumber}";
38 | docDbEvent.EventId = @event.EventId;
39 | docDbEvent.Body = JObject.FromObject(@event.EventBody, serializer);
40 | docDbEvent.BodyType = typeMap.GetNameFromType(@event.EventBody.GetType());
41 | if (@event.Metadata != null)
42 | {
43 | docDbEvent.Metadata = JObject.FromObject(@event.Metadata, serializer);
44 | docDbEvent.MetadataType = typeMap.GetNameFromType(@event.Metadata.GetType());
45 | }
46 | docDbEvent.StreamId = @event.StreamId;
47 | docDbEvent.EventNumber = @event.EventNumber;
48 |
49 | return docDbEvent;
50 | }
51 |
52 | public static CosmosDbStorageEvent FromDocument(ItemResponse document)
53 | {
54 | return document.Resource;
55 | }
56 |
57 | public StorageEvent ToStorageEvent(ISerializationTypeMap typeMap, JsonSerializer serializer)
58 | {
59 | object body = Body.ToObject(typeMap.GetTypeFromName(BodyType), serializer);
60 | object metadata = Metadata?.ToObject(typeMap.GetTypeFromName(MetadataType), serializer);
61 | return new StorageEvent(StreamId, new EventData(EventId, body, metadata), EventNumber);
62 | }
63 | }
64 | }
--------------------------------------------------------------------------------
/src/SimpleEventStore/SimpleEventStore.CosmosDb/DatabaseOptions.cs:
--------------------------------------------------------------------------------
1 | namespace SimpleEventStore.CosmosDb
2 | {
3 | public class DatabaseOptions
4 | {
5 | public int? DatabaseRequestUnits {get; set;}
6 | }
7 | }
--------------------------------------------------------------------------------
/src/SimpleEventStore/SimpleEventStore.CosmosDb/DefaultSerializationTypeMap.cs:
--------------------------------------------------------------------------------
1 | using System;
2 |
3 | namespace SimpleEventStore.CosmosDb
4 | {
5 | public class DefaultSerializationTypeMap : ISerializationTypeMap
6 | {
7 | public string GetNameFromType(Type type)
8 | {
9 | return type.AssemblyQualifiedName;
10 | }
11 |
12 | public Type GetTypeFromName(string typeName)
13 | {
14 | return Type.GetType(typeName);
15 | }
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/src/SimpleEventStore/SimpleEventStore.CosmosDb/ISerializationTypeMap.cs:
--------------------------------------------------------------------------------
1 | using System;
2 |
3 | namespace SimpleEventStore.CosmosDb
4 | {
5 | public interface ISerializationTypeMap
6 | {
7 | Type GetTypeFromName(string typeName);
8 |
9 | string GetNameFromType(Type type);
10 | }
11 | }
--------------------------------------------------------------------------------
/src/SimpleEventStore/SimpleEventStore.CosmosDb/JsonNetCosmosSerializer.cs:
--------------------------------------------------------------------------------
1 | using System.IO;
2 | using System.Text;
3 | using Microsoft.Azure.Cosmos;
4 | using Newtonsoft.Json;
5 |
6 | namespace SimpleEventStore.CosmosDb
7 | {
8 | public class CosmosJsonNetSerializer : CosmosSerializer
9 | {
10 | private static readonly Encoding DefaultEncoding = new UTF8Encoding(false, true);
11 | public JsonSerializer JsonSerializer { get; }
12 | private readonly JsonSerializerSettings serializerSettings;
13 |
14 | public CosmosJsonNetSerializer() : this(new JsonSerializerSettings())
15 | {
16 | }
17 |
18 | public CosmosJsonNetSerializer(JsonSerializerSettings serializerSettings)
19 | {
20 | this.serializerSettings = serializerSettings;
21 | JsonSerializer = JsonSerializer.Create(this.serializerSettings);
22 | }
23 |
24 | public override T FromStream(Stream stream)
25 | {
26 | using (stream)
27 | {
28 | if (typeof(Stream).IsAssignableFrom(typeof(T)))
29 | {
30 | return (T)(object) stream;
31 | }
32 |
33 | using (StreamReader sr = new StreamReader(stream))
34 | {
35 | using (JsonTextReader jsonTextReader = new JsonTextReader(sr))
36 | {
37 | return JsonSerializer.Deserialize(jsonTextReader);
38 | }
39 | }
40 | }
41 | }
42 |
43 | public override Stream ToStream(T input)
44 | {
45 | MemoryStream streamPayload = new MemoryStream();
46 | using (StreamWriter streamWriter = new StreamWriter(streamPayload, encoding: DefaultEncoding, bufferSize: 1024, leaveOpen: true))
47 | {
48 | using (JsonWriter writer = new JsonTextWriter(streamWriter))
49 | {
50 | writer.Formatting = Formatting.None;
51 | JsonSerializer.Serialize(writer, input);
52 | writer.Flush();
53 | streamWriter.Flush();
54 | }
55 | }
56 |
57 | streamPayload.Position = 0;
58 | return streamPayload;
59 | }
60 | }
61 | }
--------------------------------------------------------------------------------
/src/SimpleEventStore/SimpleEventStore.CosmosDb/LoggingOptions.cs:
--------------------------------------------------------------------------------
1 | using System;
2 |
3 | namespace SimpleEventStore.CosmosDb
4 | {
5 | public class LoggingOptions
6 | {
7 | public Action Success { get; set; }
8 |
9 | internal void OnSuccess(ResponseInformation response)
10 | {
11 | Success?.Invoke(response);
12 | }
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/src/SimpleEventStore/SimpleEventStore.CosmosDb/Resources.cs:
--------------------------------------------------------------------------------
1 | using System.IO;
2 | using System.Reflection;
3 |
4 | namespace SimpleEventStore.CosmosDb
5 | {
6 | internal static class Resources
7 | {
8 | public static string GetString(string resourceName)
9 | {
10 | using (var reader = new StreamReader(GetStream(resourceName)))
11 | {
12 | return reader.ReadToEnd();
13 | }
14 | }
15 |
16 | private static Stream GetStream(string resourceName)
17 | {
18 | resourceName = $"{typeof(Resources).FullName}.{resourceName}";
19 | return typeof(Resources).GetTypeInfo().Assembly.GetManifestResourceStream(resourceName);
20 | }
21 | }
22 | }
--------------------------------------------------------------------------------
/src/SimpleEventStore/SimpleEventStore.CosmosDb/ResponseInformation.cs:
--------------------------------------------------------------------------------
1 | using System.Collections.Specialized;
2 | using System.Globalization;
3 | using Microsoft.Azure.Cosmos;
4 | using Microsoft.Azure.Cosmos.Scripts;
5 |
6 | namespace SimpleEventStore.CosmosDb
7 | {
8 | public class ResponseInformation
9 | {
10 | public string RequestIdentifier { get; private set; }
11 |
12 | public string CurrentResourceQuotaUsage { get; private set; }
13 |
14 | public string MaxResourceQuota { get; private set; }
15 |
16 | public double RequestCharge { get; private set; }
17 |
18 | public NameValueCollection ResponseHeaders { get; private set; }
19 |
20 | public static ResponseInformation FromWriteResponse(string requestIdentifier, StoredProcedureExecuteResponse response)
21 | {
22 | return new ResponseInformation
23 | {
24 | RequestIdentifier = requestIdentifier,
25 | CurrentResourceQuotaUsage = GetCurrentResourceQuotaUsage(response),
26 | MaxResourceQuota = GetMaxResourceQuota(response),
27 | RequestCharge = response.RequestCharge,
28 | ResponseHeaders = HeaderToNamedValueCollection(response.Headers)
29 | };
30 | }
31 |
32 | public static ResponseInformation FromWriteResponse(string requestIdentifier, TransactionalBatchResponse response)
33 | {
34 | return new ResponseInformation
35 | {
36 | RequestIdentifier = requestIdentifier,
37 | CurrentResourceQuotaUsage = GetCurrentResourceQuotaUsage(response),
38 | RequestCharge = response.RequestCharge
39 | };
40 | }
41 |
42 | private static string GetCurrentResourceQuotaUsage(TransactionalBatchResponse response)
43 | {
44 | return response.RequestCharge.ToString();
45 | }
46 |
47 | private static string GetCurrentResourceQuotaUsage(Response response)
48 | {
49 | return response.Headers?.GetValueOrDefault("x-ms-resource-usage");
50 | }
51 |
52 | private static string GetMaxResourceQuota(Response response)
53 | {
54 | return response.Headers?.GetValueOrDefault("x-ms-resource-quota");
55 | }
56 |
57 | public static ResponseInformation FromReadResponse(string requestIdentifier, FeedResponse response)
58 | {
59 | return new ResponseInformation
60 | {
61 | RequestIdentifier = requestIdentifier,
62 | CurrentResourceQuotaUsage = GetCurrentResourceQuotaUsage(response),
63 | MaxResourceQuota = GetMaxResourceQuota(response),
64 | RequestCharge = response.RequestCharge,
65 | ResponseHeaders = HeaderToNamedValueCollection(response.Headers)
66 | };
67 | }
68 |
69 | public static ResponseInformation FromSubscriptionReadResponse(string requestIdentifier, Response response)
70 | {
71 | return new ResponseInformation
72 | {
73 | RequestIdentifier = requestIdentifier,
74 | CurrentResourceQuotaUsage = GetCurrentResourceQuotaUsage(response),
75 | MaxResourceQuota = GetMaxResourceQuota(response),
76 | RequestCharge = response.RequestCharge,
77 | ResponseHeaders = HeaderToNamedValueCollection(response.Headers)
78 | };
79 | }
80 |
81 | private static NameValueCollection HeaderToNamedValueCollection(Headers headers)
82 | {
83 | return new NameValueCollection()
84 | {
85 | {"Location", headers?.Location},
86 | {"Session", headers?.Session},
87 | {"RequestCharge", headers?.RequestCharge.ToString(CultureInfo.InvariantCulture)},
88 | {"ActivityId", headers?.ActivityId},
89 | {"ContentLength", headers?.ContentLength},
90 | {"ContentType", headers?.ContentType},
91 | {"ContinuationToken", headers?.ContinuationToken},
92 | {"ETag", headers?.ETag}
93 | };
94 | }
95 | }
96 | }
--------------------------------------------------------------------------------
/src/SimpleEventStore/SimpleEventStore.CosmosDb/SimpleEventStore.AzureDocumentDb.nuspec:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Asos.SimpleEventStore.AzureDocumentDb
5 | $version$
6 | $author$
7 | $author$
8 | https://github.com/ASOS/SimpleEventStore
9 | false
10 | $description$
11 | eventsourcing documentdb azure
12 |
13 |
--------------------------------------------------------------------------------
/src/SimpleEventStore/SimpleEventStore.CosmosDb/SimpleEventStore.CosmosDb.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | netstandard2.0
5 | 1.0.0
6 | SimpleEventStore.CosmosDb
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 | Asos.SimpleEventStore.CosmosDb
16 | Uses Azure Cosmos DB as the stream database for Simple Event Store (SES)
17 | ASOS
18 | Copyright ASOS © 2024
19 | SimpleEventStore.CosmosDb
20 | Asos.SimpleEventStore.CosmosDb
21 | ASOS
22 | eventsourcing azure cosmosdb
23 | https://github.com/ASOS/SimpleEventStore
24 |
25 | library
26 | $(BuildVersion)
27 | $(BuildVersion)
28 |
29 |
--------------------------------------------------------------------------------
/src/SimpleEventStore/SimpleEventStore.Tests/EventDataTests.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using NUnit.Framework;
3 |
4 | namespace SimpleEventStore.Tests
5 | {
6 | [TestFixture]
7 | public class EventDataTests
8 | {
9 | [Test]
10 | public void when_creating_an_instance_the_event_body_must_be_supplied()
11 | {
12 | Assert.Throws(() => new EventData(Guid.NewGuid(), null));
13 | }
14 |
15 | [Test]
16 | public void when_creating_an_instance_the_properties_are_mapped()
17 | {
18 | var eventId = Guid.NewGuid();
19 | var sut = new EventData(eventId, "BODY", "METADATA");
20 |
21 | Assert.That(sut.EventId, Is.EqualTo(eventId));
22 | Assert.That(sut.Body, Is.EqualTo("BODY"));
23 | Assert.That(sut.Metadata, Is.EqualTo("METADATA"));
24 | }
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/src/SimpleEventStore/SimpleEventStore.Tests/EventStoreAppending.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Linq;
3 | using System.Threading;
4 | using System.Threading.Tasks;
5 | using NUnit.Framework;
6 | using SimpleEventStore.Tests.Events;
7 |
8 | namespace SimpleEventStore.Tests
9 | {
10 | [TestFixture]
11 | public abstract class EventStoreAppending : EventStoreTestBase
12 | {
13 | [Test]
14 | public async Task when_appending_to_a_new_stream_the_event_is_saved()
15 | {
16 | var streamId = Guid.NewGuid().ToString();
17 | var @event = new EventData(Guid.NewGuid(), new OrderCreated(streamId));
18 |
19 | await Subject.AppendToStream(streamId, 0, @event);
20 |
21 | var stream = await Subject.ReadStreamForwards(streamId);
22 | Assert.That(stream.Count, Is.EqualTo(1));
23 | Assert.That(stream.Single().StreamId, Is.EqualTo(streamId));
24 | Assert.That(stream.Single().EventId, Is.EqualTo(@event.EventId));
25 | Assert.That(stream.Single().EventNumber, Is.EqualTo(1));
26 | }
27 |
28 | [Test]
29 | public async Task when_appending_to_an_existing_stream_the_event_is_saved()
30 | {
31 | var streamId = Guid.NewGuid().ToString();
32 | await Subject.AppendToStream(streamId, 0, new EventData(Guid.NewGuid(), new OrderCreated(streamId)));
33 | var @event = new EventData(Guid.NewGuid(), new OrderDispatched(streamId));
34 |
35 | await Subject.AppendToStream(streamId, 1, @event);
36 |
37 | var stream = await Subject.ReadStreamForwards(streamId);
38 | Assert.That(stream.Count, Is.EqualTo(2));
39 | Assert.That(stream.Skip(1).Single().EventId, Is.EqualTo(@event.EventId));
40 | Assert.That(stream.Skip(1).Single().EventNumber, Is.EqualTo(2));
41 | }
42 |
43 | [Test]
44 | [TestCase(-1)]
45 | [TestCase(1)]
46 | public void when_appending_to_a_new_stream_with_an_unexpected_version__a_concurrency_error_is_thrown(int expectedVersion)
47 | {
48 | var streamId = Guid.NewGuid().ToString();
49 | var @event = new EventData(Guid.NewGuid(), new OrderDispatched(streamId));
50 |
51 | var exception = Assert.ThrowsAsync(async () => await Subject.AppendToStream(streamId, expectedVersion, @event));
52 | Assert.That(exception.Message, Is.EqualTo($"Concurrency conflict when appending to stream {streamId}. Expected revision {expectedVersion}"));
53 | }
54 |
55 | [Test]
56 | [TestCase(0)]
57 | [TestCase(2)]
58 | public async Task when_appending_to_an_existing_stream_with_an_unexpected_version_a_concurrency_error_is_thrown(int expectedVersion)
59 | {
60 | var streamId = Guid.NewGuid().ToString();
61 | await Subject.AppendToStream(streamId, 0, new EventData(Guid.NewGuid(), new OrderCreated(streamId)));
62 |
63 | var @event = new EventData(Guid.NewGuid(), new OrderDispatched(streamId));
64 |
65 | var exception = Assert.ThrowsAsync(async () => await Subject.AppendToStream(streamId, expectedVersion, @event));
66 | Assert.That(exception.Message, Is.EqualTo($"Concurrency conflict when appending to stream {streamId}. Expected revision {expectedVersion}"));
67 | }
68 |
69 | [Test]
70 | [TestCase(null)]
71 | [TestCase("")]
72 | [TestCase(" ")]
73 | public void when_appending_to_an_invalid_stream_id_an_argument_error_is_thrown(string streamId)
74 | {
75 | Assert.ThrowsAsync(async () => await Subject.AppendToStream(streamId, 0, new EventData(Guid.NewGuid(), new OrderCreated(streamId))));
76 | }
77 |
78 | [Test]
79 | public async Task when_appending_to_a_new_stream_with_multiple_events_then_they_are_saved()
80 | {
81 | var streamId = Guid.NewGuid().ToString();
82 | var events = new []
83 | {
84 | new EventData(Guid.NewGuid(), new OrderCreated(streamId)),
85 | new EventData(Guid.NewGuid(), new OrderDispatched(streamId))
86 | };
87 |
88 | await Subject.AppendToStream(streamId, 0, events);
89 |
90 | var savedEvents = await Subject.ReadStreamForwards(streamId);
91 |
92 | Assert.That(savedEvents.Count, Is.EqualTo(2));
93 | Assert.That(savedEvents.First().StreamId, Is.EqualTo(streamId));
94 | Assert.That(savedEvents.First().EventNumber, Is.EqualTo(1));
95 | Assert.That(savedEvents.Skip(1).Single().StreamId, Is.EqualTo(streamId));
96 | Assert.That(savedEvents.Skip(1).Single().EventNumber, Is.EqualTo(2));
97 | }
98 |
99 | [Test]
100 | public async Task when_appending_to_a_new_stream_the_event_metadata_is_saved()
101 | {
102 | var streamId = Guid.NewGuid().ToString();
103 | var metadata = new TestMetadata { Value = "Hello" };
104 | var @event = new EventData(Guid.NewGuid(), new OrderCreated(streamId), metadata);
105 |
106 | await Subject.AppendToStream(streamId, 0, @event);
107 |
108 | var stream = await Subject.ReadStreamForwards(streamId);
109 | Assert.That(((TestMetadata)stream.Single().Metadata).Value, Is.EqualTo(metadata.Value));
110 | }
111 |
112 | [Test]
113 | public void when_appending_to_a_stream_the_engine_honours_cancellation_token()
114 | {
115 | var streamId = Guid.NewGuid().ToString();
116 | var metadata = new TestMetadata { Value = "Hello" };
117 | var @event = new EventData(Guid.NewGuid(), new OrderCreated(streamId), metadata);
118 |
119 | using (var cts = new CancellationTokenSource())
120 | {
121 | cts.Cancel();
122 |
123 | AsyncTestDelegate act = () => Subject.AppendToStream(streamId, 0, cts.Token, @event);
124 |
125 | Assert.That(act, Throws.InstanceOf());
126 | }
127 | }
128 | }
129 | }
130 |
--------------------------------------------------------------------------------
/src/SimpleEventStore/SimpleEventStore.Tests/EventStoreReading.cs:
--------------------------------------------------------------------------------
1 | using NUnit.Framework;
2 | using SimpleEventStore.Tests.Events;
3 | using System;
4 | using System.Linq;
5 | using System.Threading;
6 | using System.Threading.Tasks;
7 |
8 | namespace SimpleEventStore.Tests
9 | {
10 | // TODOs
11 | // 1. Make partioning support configurable
12 | // 2. Allow for lower levels of consistency than just strong
13 | [TestFixture]
14 | public abstract class EventStoreReading : EventStoreTestBase
15 | {
16 | [Test]
17 | public async Task when_reading_a_stream_which_has_no_events_an_empty_list_is_returned()
18 | {
19 | var streamId = Guid.NewGuid().ToString();
20 |
21 | var events = await Subject.ReadStreamForwards(streamId);
22 |
23 | Assert.That(events.Count, Is.EqualTo(0));
24 | }
25 |
26 | [Test]
27 | public async Task when_reading_a_stream_all_events_are_returned()
28 | {
29 | var streamId = Guid.NewGuid().ToString();
30 |
31 | await Subject.AppendToStream(streamId, 0, new EventData(Guid.NewGuid(), new OrderCreated(streamId)));
32 | await Subject.AppendToStream(streamId, 1, new EventData(Guid.NewGuid(), new OrderDispatched(streamId)));
33 |
34 | var events = await Subject.ReadStreamForwards(streamId);
35 |
36 | Assert.That(events.Count, Is.EqualTo(2));
37 | Assert.That(events.First().EventBody, Is.InstanceOf());
38 | Assert.That(events.Skip(1).Single().EventBody, Is.InstanceOf());
39 | }
40 |
41 | [Test]
42 | public async Task when_reading_a_stream_events_returned_in_correct_order()
43 | {
44 | var streamId = Guid.NewGuid().ToString();
45 |
46 | await Subject.AppendToStream(streamId, 0, new EventData(Guid.NewGuid(), new OrderCreated(streamId)));
47 | await Subject.AppendToStream(streamId, 1, new EventData(Guid.NewGuid(), new OrderDispatched(streamId)));
48 | await Subject.AppendToStream(streamId, 2, new EventData(Guid.NewGuid(), new OrderDispatched(streamId)));
49 |
50 | var events = await Subject.ReadStreamForwards(streamId);
51 |
52 | Assert.That(events.Count, Is.EqualTo(3));
53 | Assert.That(events.First().EventNumber, Is.EqualTo(1));
54 | Assert.That(events.Skip(1).First().EventNumber, Is.EqualTo(2));
55 | Assert.That(events.Skip(2).First().EventNumber, Is.EqualTo(3));
56 | }
57 |
58 | [Test]
59 | [TestCase(null)]
60 | [TestCase("")]
61 | [TestCase(" ")]
62 | public void when_reading_from_an_invalid_stream_id_an_argument_error_is_thrown(string streamId)
63 | {
64 | Assert.ThrowsAsync(async () => await Subject.ReadStreamForwards(streamId));
65 | }
66 |
67 | [Test]
68 | public async Task when_reading_a_stream_only_the_required_events_are_returned()
69 | {
70 | var streamId = Guid.NewGuid().ToString();
71 |
72 | await Subject.AppendToStream(streamId, 0, new EventData(Guid.NewGuid(), new OrderCreated(streamId)));
73 | await Subject.AppendToStream(streamId, 1, new EventData(Guid.NewGuid(), new OrderDispatched(streamId)));
74 | await Subject.AppendToStream(streamId, 2, new EventData(Guid.NewGuid(), new OrderProcessed(streamId, default)));
75 |
76 | var events = await Subject.ReadStreamForwards(streamId, startPosition: 2, numberOfEventsToRead: 1);
77 |
78 | Assert.That(events.Count, Is.EqualTo(1));
79 | Assert.That(events.First().EventBody, Is.InstanceOf());
80 | }
81 |
82 | [Test]
83 | public void when_reading_a_stream_the_engine_honours_cancellation_token()
84 | {
85 | var streamId = Guid.NewGuid().ToString();
86 |
87 | using (var cts = new CancellationTokenSource())
88 | {
89 | cts.Cancel();
90 |
91 | AsyncTestDelegate act = () => Subject.ReadStreamForwards(streamId, cts.Token);
92 |
93 | Assert.That(act, Throws.InstanceOf());
94 | }
95 | }
96 | }
97 | }
98 |
--------------------------------------------------------------------------------
/src/SimpleEventStore/SimpleEventStore.Tests/EventStoreTestBase.cs:
--------------------------------------------------------------------------------
1 | using NUnit.Framework;
2 | using System.Threading.Tasks;
3 |
4 | namespace SimpleEventStore.Tests
5 | {
6 | public abstract class EventStoreTestBase
7 | {
8 | protected EventStore Subject { get; private set; }
9 |
10 | [OneTimeSetUp]
11 | public async Task OneTimeSetUp()
12 | {
13 | var storageEngine = await CreateStorageEngine();
14 | Subject = new EventStore(storageEngine);
15 | }
16 |
17 |
18 | protected abstract Task CreateStorageEngine();
19 | }
20 | }
--------------------------------------------------------------------------------
/src/SimpleEventStore/SimpleEventStore.Tests/Events/OrderCreated.cs:
--------------------------------------------------------------------------------
1 | namespace SimpleEventStore.Tests.Events
2 | {
3 | public class OrderCreated
4 | {
5 | public string OrderId { get; private set; }
6 |
7 | public OrderCreated(string orderId)
8 | {
9 | OrderId = orderId;
10 | }
11 | }
12 | }
--------------------------------------------------------------------------------
/src/SimpleEventStore/SimpleEventStore.Tests/Events/OrderDispatched.cs:
--------------------------------------------------------------------------------
1 | namespace SimpleEventStore.Tests.Events
2 | {
3 | public class OrderDispatched
4 | {
5 | public string OrderId { get; private set; }
6 |
7 | public OrderDispatched(string orderId)
8 | {
9 | OrderId = orderId;
10 | }
11 | }
12 | }
--------------------------------------------------------------------------------
/src/SimpleEventStore/SimpleEventStore.Tests/Events/OrderProcessed.cs:
--------------------------------------------------------------------------------
1 | using System;
2 |
3 | namespace SimpleEventStore.Tests.Events
4 | {
5 | public class OrderProcessed
6 | {
7 | public string OrderId { get; private set; }
8 |
9 | public Version OrderProcessorVersion { get; set; }
10 |
11 | public OrderProcessed(string orderId, Version orderProcessorVersion)
12 | {
13 | OrderId = orderId;
14 | OrderProcessorVersion = orderProcessorVersion;
15 | }
16 | }
17 | }
--------------------------------------------------------------------------------
/src/SimpleEventStore/SimpleEventStore.Tests/Events/TestMetadata.cs:
--------------------------------------------------------------------------------
1 | namespace SimpleEventStore.Tests.Events
2 | {
3 | public class TestMetadata
4 | {
5 | public string Value { get; set; }
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/src/SimpleEventStore/SimpleEventStore.Tests/InMemory/InMemoryEventStoreAppending.cs:
--------------------------------------------------------------------------------
1 | using System.Threading.Tasks;
2 | using NUnit.Framework;
3 | using SimpleEventStore.InMemory;
4 |
5 | namespace SimpleEventStore.Tests.InMemory
6 | {
7 | [TestFixture]
8 | public class InMemoryEventStoreAppending : EventStoreAppending
9 | {
10 | protected override Task CreateStorageEngine()
11 | {
12 | return Task.FromResult((IStorageEngine)new InMemoryStorageEngine());
13 | }
14 | }
15 | }
--------------------------------------------------------------------------------
/src/SimpleEventStore/SimpleEventStore.Tests/InMemory/InMemoryEventStoreReading.cs:
--------------------------------------------------------------------------------
1 | using System.Threading.Tasks;
2 | using NUnit.Framework;
3 | using SimpleEventStore.InMemory;
4 |
5 | namespace SimpleEventStore.Tests.InMemory
6 | {
7 | [TestFixture]
8 | public class InMemoryEventStoreReading : EventStoreReading
9 | {
10 | protected override Task CreateStorageEngine()
11 | {
12 | return Task.FromResult((IStorageEngine)new InMemoryStorageEngine());
13 | }
14 | }
15 | }
--------------------------------------------------------------------------------
/src/SimpleEventStore/SimpleEventStore.Tests/SimpleEventStore.Tests.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | net8.0
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 | SimpleEventStore.Tests
16 | ASOS
17 | Copyright ASOS © 2024
18 | SimpleEventStore.Tests
19 |
20 |
--------------------------------------------------------------------------------
/src/SimpleEventStore/SimpleEventStore.Tests/StorageEventTests.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using NUnit.Framework;
3 |
4 | namespace SimpleEventStore.Tests
5 | {
6 | [TestFixture]
7 | public class StorageEventTests
8 | {
9 | [Test]
10 | public void when_creating_a_new_instance_the_properties_are_mapped()
11 | {
12 | var eventId = Guid.NewGuid();
13 | var @event = new EventData(eventId, "BODY", "METADATA");
14 |
15 | var sut = new StorageEvent("STREAMID", @event, 1);
16 |
17 | Assert.That(sut.StreamId, Is.EqualTo("STREAMID"));
18 | Assert.That(sut.EventBody, Is.EqualTo("BODY"));
19 | Assert.That(sut.Metadata, Is.EqualTo("METADATA"));
20 | Assert.That(sut.EventNumber, Is.EqualTo(1));
21 | Assert.That(sut.EventId, Is.EqualTo(eventId));
22 | }
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/src/SimpleEventStore/SimpleEventStore.sln:
--------------------------------------------------------------------------------
1 |
2 | Microsoft Visual Studio Solution File, Format Version 12.00
3 | # Visual Studio 14
4 | VisualStudioVersion = 14.0.25420.1
5 | MinimumVisualStudioVersion = 10.0.40219.1
6 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SimpleEventStore", "SimpleEventStore\SimpleEventStore.csproj", "{73235465-69BF-4762-B8C5-20C8E45795FF}"
7 | EndProject
8 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{CD241C9A-0A56-42C9-8309-D68890C78B64}"
9 | EndProject
10 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SimpleEventStore.Tests", "SimpleEventStore.Tests\SimpleEventStore.Tests.csproj", "{ACA6B3AE-FCB9-45F4-9D6B-66196F98F819}"
11 | EndProject
12 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SimpleEventStore.CosmosDb", "SimpleEventStore.CosmosDb\SimpleEventStore.CosmosDb.csproj", "{48C71940-D9B0-446A-9F3D-E6275CD43440}"
13 | EndProject
14 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SimpleEventStore.CosmosDb.Tests", "SimpleEventStore.CosmosDb.Tests\SimpleEventStore.CosmosDb.Tests.csproj", "{205A7F81-A496-4400-9A97-D156F88B7883}"
15 | EndProject
16 | Global
17 | GlobalSection(SolutionConfigurationPlatforms) = preSolution
18 | Debug|Any CPU = Debug|Any CPU
19 | Release|Any CPU = Release|Any CPU
20 | EndGlobalSection
21 | GlobalSection(ProjectConfigurationPlatforms) = postSolution
22 | {73235465-69BF-4762-B8C5-20C8E45795FF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
23 | {73235465-69BF-4762-B8C5-20C8E45795FF}.Debug|Any CPU.Build.0 = Debug|Any CPU
24 | {73235465-69BF-4762-B8C5-20C8E45795FF}.Release|Any CPU.ActiveCfg = Release|Any CPU
25 | {73235465-69BF-4762-B8C5-20C8E45795FF}.Release|Any CPU.Build.0 = Release|Any CPU
26 | {ACA6B3AE-FCB9-45F4-9D6B-66196F98F819}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
27 | {ACA6B3AE-FCB9-45F4-9D6B-66196F98F819}.Debug|Any CPU.Build.0 = Debug|Any CPU
28 | {ACA6B3AE-FCB9-45F4-9D6B-66196F98F819}.Release|Any CPU.ActiveCfg = Release|Any CPU
29 | {ACA6B3AE-FCB9-45F4-9D6B-66196F98F819}.Release|Any CPU.Build.0 = Release|Any CPU
30 | {48C71940-D9B0-446A-9F3D-E6275CD43440}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
31 | {48C71940-D9B0-446A-9F3D-E6275CD43440}.Debug|Any CPU.Build.0 = Debug|Any CPU
32 | {48C71940-D9B0-446A-9F3D-E6275CD43440}.Release|Any CPU.ActiveCfg = Release|Any CPU
33 | {48C71940-D9B0-446A-9F3D-E6275CD43440}.Release|Any CPU.Build.0 = Release|Any CPU
34 | {205A7F81-A496-4400-9A97-D156F88B7883}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
35 | {205A7F81-A496-4400-9A97-D156F88B7883}.Debug|Any CPU.Build.0 = Debug|Any CPU
36 | {205A7F81-A496-4400-9A97-D156F88B7883}.Release|Any CPU.ActiveCfg = Release|Any CPU
37 | {205A7F81-A496-4400-9A97-D156F88B7883}.Release|Any CPU.Build.0 = Release|Any CPU
38 | EndGlobalSection
39 | GlobalSection(SolutionProperties) = preSolution
40 | HideSolutionNode = FALSE
41 | EndGlobalSection
42 | GlobalSection(NestedProjects) = preSolution
43 | {ACA6B3AE-FCB9-45F4-9D6B-66196F98F819} = {CD241C9A-0A56-42C9-8309-D68890C78B64}
44 | {205A7F81-A496-4400-9A97-D156F88B7883} = {CD241C9A-0A56-42C9-8309-D68890C78B64}
45 | EndGlobalSection
46 | EndGlobal
47 |
--------------------------------------------------------------------------------
/src/SimpleEventStore/SimpleEventStore/ConcurrencyException.cs:
--------------------------------------------------------------------------------
1 | using System;
2 |
3 | namespace SimpleEventStore
4 | {
5 | public class ConcurrencyException : Exception
6 | {
7 | //
8 | // For guidelines regarding the creation of new exception types, see
9 | // http://msdn.microsoft.com/library/default.asp?url=/library/en-us/cpgenref/html/cpconerrorraisinghandlingguidelines.asp
10 | // and
11 | // http://msdn.microsoft.com/library/default.asp?url=/library/en-us/dncscol/html/csharp07192001.asp
12 | //
13 |
14 | public ConcurrencyException()
15 | {
16 | }
17 |
18 | public ConcurrencyException(string message) : base(message)
19 | {
20 | }
21 |
22 | public ConcurrencyException(string message, Exception inner) : base(message, inner)
23 | {
24 | }
25 | }
26 | }
--------------------------------------------------------------------------------
/src/SimpleEventStore/SimpleEventStore/EventData.cs:
--------------------------------------------------------------------------------
1 | using System;
2 |
3 | namespace SimpleEventStore
4 | {
5 | public class EventData
6 | {
7 | public Guid EventId { get; private set; }
8 |
9 | public object Body { get; private set; }
10 |
11 | public object Metadata { get; private set; }
12 |
13 | public EventData(Guid eventId, object body, object metadata = null)
14 | {
15 | Guard.IsNotNull(nameof(body), body);
16 |
17 | EventId = eventId;
18 | Body = body;
19 | Metadata = metadata;
20 | }
21 | }
22 | }
--------------------------------------------------------------------------------
/src/SimpleEventStore/SimpleEventStore/EventStore.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.Generic;
3 | using System.Threading;
4 | using System.Threading.Tasks;
5 |
6 | namespace SimpleEventStore
7 | {
8 | public class EventStore
9 | {
10 | private readonly IStorageEngine engine;
11 |
12 | public EventStore(IStorageEngine engine)
13 | {
14 | this.engine = engine;
15 | }
16 |
17 | public Task AppendToStream(string streamId, int expectedVersion, params EventData[] events)
18 | {
19 | return AppendToStream(streamId, expectedVersion, default, events);
20 | }
21 |
22 | public Task AppendToStream(string streamId, int expectedVersion, CancellationToken cancellationToken, params EventData[] events)
23 | {
24 | Guard.IsNotNullOrEmpty(nameof(streamId), streamId);
25 |
26 | var storageEvents = new List();
27 | var eventVersion = expectedVersion;
28 |
29 | for (int i = 0; i < events.Length; i++)
30 | {
31 | storageEvents.Add(new StorageEvent(streamId, events[i], ++eventVersion));
32 | }
33 |
34 | return engine.AppendToStream(streamId, storageEvents, cancellationToken);
35 | }
36 |
37 | public Task> ReadStreamForwards(string streamId, CancellationToken cancellationToken = default)
38 | {
39 | Guard.IsNotNullOrEmpty(nameof(streamId), streamId);
40 |
41 | return engine.ReadStreamForwards(streamId, 1, Int32.MaxValue, cancellationToken);
42 | }
43 |
44 | public Task> ReadStreamForwards(string streamId, int startPosition, int numberOfEventsToRead, CancellationToken cancellationToken = default)
45 | {
46 | Guard.IsNotNullOrEmpty(nameof(streamId), streamId);
47 |
48 | return engine.ReadStreamForwards(streamId, startPosition, numberOfEventsToRead, cancellationToken);
49 | }
50 | }
51 | }
--------------------------------------------------------------------------------
/src/SimpleEventStore/SimpleEventStore/Guard.cs:
--------------------------------------------------------------------------------
1 | using System;
2 |
3 | namespace SimpleEventStore
4 | {
5 | public static class Guard
6 | {
7 | public static void IsNotNullOrEmpty(string paramName, string value)
8 | {
9 | if (string.IsNullOrWhiteSpace(value))
10 | {
11 | throw new ArgumentException("The value cannot be a null, empty string or contain only whitespace", paramName);
12 | }
13 | }
14 |
15 | public static void IsNotNull(string paramName, object value)
16 | {
17 | if (value == null)
18 | {
19 | throw new ArgumentNullException(paramName, "The value cannot be null");
20 | }
21 | }
22 | }
23 | }
--------------------------------------------------------------------------------
/src/SimpleEventStore/SimpleEventStore/IStorageEngine.cs:
--------------------------------------------------------------------------------
1 | using System.Collections.Generic;
2 | using System.Threading;
3 | using System.Threading.Tasks;
4 |
5 | namespace SimpleEventStore
6 | {
7 | public interface IStorageEngine
8 | {
9 | Task AppendToStream(string streamId, IEnumerable events, CancellationToken cancellationToken = default);
10 |
11 | Task> ReadStreamForwards(string streamId, int startPosition, int numberOfEventsToRead, CancellationToken cancellationToken = default);
12 |
13 | Task Initialise(CancellationToken cancellationToken = default);
14 | }
15 | }
--------------------------------------------------------------------------------
/src/SimpleEventStore/SimpleEventStore/InMemory/InMemoryStorageEngine.cs:
--------------------------------------------------------------------------------
1 | using System.Collections.Concurrent;
2 | using System.Collections.Generic;
3 | using System.Linq;
4 | using System.Threading;
5 | using System.Threading.Tasks;
6 |
7 | namespace SimpleEventStore.InMemory
8 | {
9 | public class InMemoryStorageEngine : IStorageEngine
10 | {
11 | private static readonly IReadOnlyCollection EmptyStream = new StorageEvent[0];
12 |
13 | private readonly ConcurrentDictionary> streams = new ConcurrentDictionary>();
14 | private readonly List allEvents = new List();
15 |
16 | public Task AppendToStream(string streamId, IEnumerable events, CancellationToken cancellationToken = default)
17 | {
18 | return Task.Run(() =>
19 | {
20 | if (!streams.ContainsKey(streamId))
21 | {
22 | streams[streamId] = new List();
23 | }
24 |
25 | var firstEvent = events.First();
26 |
27 | if (firstEvent.EventNumber - 1 != streams[streamId].Count)
28 | {
29 | throw new ConcurrencyException($"Concurrency conflict when appending to stream {streamId}. Expected revision {firstEvent.EventNumber - 1}");
30 | }
31 |
32 | cancellationToken.ThrowIfCancellationRequested();
33 |
34 | streams[streamId].AddRange(events);
35 | AddEventsToAllStream(events);
36 | }, cancellationToken);
37 | }
38 |
39 | private void AddEventsToAllStream(IEnumerable events)
40 | {
41 | foreach (var e in events)
42 | {
43 | allEvents.Add(e);
44 | }
45 | }
46 |
47 | public Task> ReadStreamForwards(string streamId, int startPosition, int numberOfEventsToRead, CancellationToken cancellationToken = default)
48 | {
49 | cancellationToken.ThrowIfCancellationRequested();
50 |
51 | if (!streams.ContainsKey(streamId))
52 | {
53 | return Task.FromResult(EmptyStream);
54 | }
55 |
56 | IReadOnlyCollection stream = streams[streamId].Skip(startPosition - 1).Take(numberOfEventsToRead).ToList().AsReadOnly();
57 | return Task.FromResult(stream);
58 | }
59 |
60 | public Task Initialise(CancellationToken cancellationToken = default)
61 | {
62 | cancellationToken.ThrowIfCancellationRequested();
63 |
64 | return Task.FromResult(this);
65 | }
66 | }
67 | }
--------------------------------------------------------------------------------
/src/SimpleEventStore/SimpleEventStore/SimpleEventStore.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | netstandard2.0
5 | 1.0.0
6 |
7 |
8 | ASOS.SimpleEventStore
9 | Simple Event Store (SES) provides a lightweight event sourcing abstraction.
10 | ASOS
11 | Copyright ASOS © 2024
12 | SimpleEventStore
13 | Asos.SimpleEventStore
14 | ASOS
15 | eventsourcing
16 | https://github.com/ASOS/SimpleEventStore
17 | $(BuildVersion)
18 | $(BuildVersion)
19 |
20 |
--------------------------------------------------------------------------------
/src/SimpleEventStore/SimpleEventStore/StorageEvent.cs:
--------------------------------------------------------------------------------
1 | using System;
2 |
3 | namespace SimpleEventStore
4 | {
5 | public class StorageEvent
6 | {
7 | public string StreamId { get; private set; }
8 |
9 | public object EventBody { get; private set; }
10 |
11 | public object Metadata { get; private set; }
12 |
13 | public int EventNumber { get; private set; }
14 |
15 | public Guid EventId { get; private set; }
16 |
17 | public StorageEvent(string streamId, EventData data, int eventNumber)
18 | {
19 | StreamId = streamId;
20 | EventBody = data.Body;
21 | Metadata = data.Metadata;
22 | EventNumber = eventNumber;
23 | EventId = data.EventId;
24 | }
25 | }
26 | }
27 |
--------------------------------------------------------------------------------