Skip to content

Commit d23021f

Browse files
committed
Add benchmark tests for MongoDB persistence and implement snapshot functionality
- Introduced `benchmark-snapshot.ps1` script for creating performance snapshots. - Added various benchmark classes to measure performance of checkpoint generators, duplicate conflicts, global checkpoint reads, and recycle bin reads. - Implemented async benchmarks for reading from and writing to the event store. - Enhanced `EventStoreHelpers` to ensure BSON serializers are registered for compatibility. - Updated project files to support new benchmarks and ensure proper configuration. - Modified `Program.cs` to utilize `BenchmarkSwitcher` for running benchmarks.
1 parent f664828 commit d23021f

17 files changed

Lines changed: 1201 additions & 63 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,5 +112,6 @@ Backup*/
112112
UpgradeLog*.XML
113113

114114
# Custom
115+
BenchmarkDotNet.Artifacts/
115116
artifacts/
116117
.tokensave

README.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,39 @@ To build the project locally on a Windows Machine:
5252
NEventStore.MongoDB="mongodb://localhost:50002/NEventStore"
5353
```
5454

55+
## Run Benchmarks (locally)
56+
57+
- Build benchmark project:
58+
59+
```powershell
60+
dotnet build .\src\NEventStore.Persistence.MongoDB.Benchmark\NEventStore.Persistence.MongoDB.Benchmark.csproj -c Release
61+
```
62+
63+
- Set benchmark connection string in current shell:
64+
65+
```powershell
66+
$env:NEventStore.MongoDB = 'mongodb://localhost:50002/NEventStore'
67+
```
68+
69+
- List all discovered benchmark cases:
70+
71+
```powershell
72+
dotnet .\src\NEventStore.Persistence.MongoDB.Benchmark\bin\Release\net8.0\NEventStore.Persistence.MongoDB.Benchmark.dll --list flat
73+
```
74+
75+
- Run all benchmark cases:
76+
77+
```powershell
78+
dotnet .\src\NEventStore.Persistence.MongoDB.Benchmark\bin\Release\net8.0\NEventStore.Persistence.MongoDB.Benchmark.dll --filter *
79+
```
80+
81+
- Run specific benchmark class or method via filter:
82+
83+
```powershell
84+
dotnet .\src\NEventStore.Persistence.MongoDB.Benchmark\bin\Release\net8.0\NEventStore.Persistence.MongoDB.Benchmark.dll --filter *CheckpointGeneratorBenchmarks*
85+
dotnet .\src\NEventStore.Persistence.MongoDB.Benchmark\bin\Release\net8.0\NEventStore.Persistence.MongoDB.Benchmark.dll --filter *ReadFromEventStoreAsyncBenchmarks.ReadFromEventStoreAsync*
86+
```
87+
5588
## Run Tests in Visual Studio
5689

5790
To run tests in visual studio using NUnit as a Test Runner you need to explicitly exclude "Explicit Tests" from running adding the following filter in the test explorer section:

docs/Performance-Investigation.md

Lines changed: 335 additions & 0 deletions
Large diffs are not rendered by default.

scripts/benchmark-snapshot.ps1

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
[CmdletBinding()]
2+
param(
3+
[ValidateSet('baseline', 'after')]
4+
[string]$SnapshotType = 'baseline',
5+
6+
[string]$OptimizationId,
7+
8+
[string]$Framework = 'net10.0',
9+
10+
[string]$Filter = '*',
11+
12+
[switch]$ShortRun,
13+
14+
[string]$ConnectionString = 'mongodb://localhost:50002/NEventStore',
15+
16+
[switch]$SkipBuild
17+
)
18+
19+
Set-StrictMode -Version Latest
20+
$ErrorActionPreference = 'Stop'
21+
22+
if ($SnapshotType -eq 'after' -and [string]::IsNullOrWhiteSpace($OptimizationId))
23+
{
24+
throw "Parameter -OptimizationId is required when -SnapshotType after is used."
25+
}
26+
27+
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
28+
$benchmarkProject = Join-Path $repoRoot 'src/NEventStore.Persistence.MongoDB.Benchmark/NEventStore.Persistence.MongoDB.Benchmark.csproj'
29+
$benchmarkDll = Join-Path $repoRoot "src/NEventStore.Persistence.MongoDB.Benchmark/bin/Release/$Framework/NEventStore.Persistence.MongoDB.Benchmark.dll"
30+
$resultsDir = Join-Path $repoRoot 'BenchmarkDotNet.Artifacts/results'
31+
$archiveRoot = Join-Path $repoRoot 'artifacts/benchmark-snapshots'
32+
33+
Write-Host "Repository root: $repoRoot"
34+
Write-Host "Snapshot type: $SnapshotType"
35+
Write-Host "Framework: $Framework"
36+
Write-Host "Filter: $Filter"
37+
38+
if (-not $SkipBuild)
39+
{
40+
Write-Host "Building benchmark project for $Framework..."
41+
dotnet build $benchmarkProject -c Release -f $Framework -v q
42+
if ($LASTEXITCODE -ne 0)
43+
{
44+
throw "dotnet build failed with exit code $LASTEXITCODE"
45+
}
46+
}
47+
48+
if (-not (Test-Path $benchmarkDll))
49+
{
50+
throw "Benchmark binary not found: $benchmarkDll"
51+
}
52+
53+
if (Test-Path $resultsDir)
54+
{
55+
Remove-Item $resultsDir -Recurse -Force
56+
}
57+
New-Item -ItemType Directory -Path $resultsDir -Force | Out-Null
58+
New-Item -ItemType Directory -Path $archiveRoot -Force | Out-Null
59+
60+
[Environment]::SetEnvironmentVariable('NEventStore.MongoDB', $ConnectionString, 'Process')
61+
Write-Host 'Running BenchmarkDotNet...'
62+
63+
$dotnetArgs = @(
64+
$benchmarkDll,
65+
'--filter', $Filter
66+
)
67+
68+
if ($ShortRun)
69+
{
70+
$dotnetArgs += @('--job', 'short')
71+
}
72+
73+
& dotnet @dotnetArgs
74+
if ($LASTEXITCODE -ne 0)
75+
{
76+
throw "Benchmark execution failed with exit code $LASTEXITCODE"
77+
}
78+
79+
$reportFiles = Get-ChildItem -Path $resultsDir -Filter '*-report-github.md' -File
80+
if (-not $reportFiles)
81+
{
82+
throw "No benchmark report files found in $resultsDir"
83+
}
84+
85+
$timestamp = Get-Date -Format 'yyyyMMdd-HHmm'
86+
$frameworkTag = $Framework -replace '[^A-Za-z0-9.-]', '-'
87+
88+
if ($SnapshotType -eq 'baseline')
89+
{
90+
$archiveName = "benchmark-baseline-$frameworkTag-$timestamp.zip"
91+
}
92+
else
93+
{
94+
$safeOptimizationId = $OptimizationId -replace '[^A-Za-z0-9._-]', '-'
95+
$archiveName = "benchmark-after-$safeOptimizationId-$frameworkTag-$timestamp.zip"
96+
}
97+
98+
$archivePath = Join-Path $archiveRoot $archiveName
99+
100+
# Compress all report files; use array expansion for proper globbing in Compress-Archive
101+
$filesToArchive = Get-ChildItem -Path $resultsDir -File | Select-Object -ExpandProperty FullName
102+
Compress-Archive -Path $filesToArchive -DestinationPath $archivePath -Force
103+
104+
$runManifest = [ordered]@{
105+
snapshotType = $SnapshotType
106+
optimizationId = $OptimizationId
107+
framework = $Framework
108+
filter = $Filter
109+
shortRun = [bool]$ShortRun
110+
connectionString = $ConnectionString
111+
createdAtUtc = (Get-Date).ToUniversalTime().ToString('o')
112+
archive = $archivePath
113+
reportCount = $reportFiles.Count
114+
reportFiles = $reportFiles.Name
115+
}
116+
117+
$manifestPath = [System.IO.Path]::ChangeExtension($archivePath, '.json')
118+
$runManifest | ConvertTo-Json -Depth 6 | Set-Content -Path $manifestPath -Encoding UTF8
119+
120+
Write-Host "Snapshot archive created: $archivePath"
121+
Write-Host "Snapshot manifest created: $manifestPath"
122+
123+
Write-Host ''
124+
Write-Host 'Use these values in docs/Performance-Investigation.md:'
125+
Write-Host '- Before Snapshot table (if baseline)'
126+
Write-Host '- After Snapshot table (if after)'
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
using BenchmarkDotNet.Attributes;
2+
using MongoDB.Bson;
3+
using NEventStore.Persistence.MongoDB;
4+
using NEventStore.Persistence.MongoDB.Benchmark.Support;
5+
using NEventStore.Persistence.MongoDB.Support;
6+
using System;
7+
8+
namespace NEventStore.Persistence.MongoDB.Benchmark.Benchmarks
9+
{
10+
/// <summary>
11+
/// Compares the per-commit checkpoint overhead of the two built-in checkpoint generators:
12+
/// - "Always": AlwaysQueryDbForNextValueCheckpointGenerator — one extra DB read per commit (default).
13+
/// - "InMemory": InMemoryCheckpointGenerator — in-memory increment; DB only on duplicate signal.
14+
/// </summary>
15+
[Config(typeof(AllowNonOptimized))]
16+
[SimpleJob(launchCount: 3, warmupCount: 3, iterationCount: 3, invocationCount: 1)]
17+
[MemoryDiagnoser]
18+
[MeanColumn, StdErrorColumn, StdDevColumn, MinColumn, MaxColumn, IterationsColumn]
19+
public class CheckpointGeneratorBenchmarks
20+
{
21+
[Params(100, 1000)]
22+
public int CommitsToWrite { get; set; }
23+
24+
[Params("Always", "InMemory")]
25+
public string GeneratorType { get; set; } = "Always";
26+
27+
private static readonly Guid StreamId = Guid.NewGuid();
28+
private IStoreEvents _eventStore = null!;
29+
30+
[GlobalSetup]
31+
public void Setup()
32+
{
33+
EventStoreHelpers.EnsureSerializersRegistered();
34+
35+
var options = new MongoPersistenceOptions();
36+
37+
if (GeneratorType == "InMemory")
38+
{
39+
var db = options.ConnectToDatabase(EventStoreHelpers.GetConnectionString());
40+
var collection = db.GetCollection<BsonDocument>("Commits");
41+
options.CheckpointGenerator = new InMemoryCheckpointGenerator(collection);
42+
}
43+
// "Always" is the engine default — leave CheckpointGenerator null.
44+
45+
_eventStore = EventStoreHelpers.WireupEventStore(options);
46+
_eventStore.Advanced.Purge();
47+
}
48+
49+
[Benchmark]
50+
public void WriteWithCheckpointGenerator()
51+
{
52+
using var stream = _eventStore.OpenStream(StreamId, 0, int.MaxValue);
53+
for (int i = 0; i < CommitsToWrite; i++)
54+
{
55+
stream.Add(new EventMessage { Body = new SomeDomainEvent { Value = i.ToString() } });
56+
stream.CommitChanges(Guid.NewGuid());
57+
}
58+
}
59+
}
60+
}
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
using BenchmarkDotNet.Attributes;
2+
using MongoDB.Bson;
3+
using MongoDB.Driver;
4+
using NEventStore.Persistence.MongoDB;
5+
using NEventStore.Persistence.MongoDB.Benchmark.Support;
6+
using NEventStore.Persistence.MongoDB.Support;
7+
using System;
8+
using System.Collections.Generic;
9+
10+
namespace NEventStore.Persistence.MongoDB.Benchmark.Benchmarks
11+
{
12+
/// <summary>
13+
/// Exercises duplicate commit-id and duplicate checkpoint retry paths without changing engine behavior.
14+
/// </summary>
15+
[Config(typeof(AllowNonOptimized))]
16+
[SimpleJob(launchCount: 3, warmupCount: 3, iterationCount: 3, invocationCount: 1)]
17+
[MemoryDiagnoser]
18+
[MeanColumn, StdErrorColumn, StdDevColumn, MinColumn, MaxColumn, IterationsColumn]
19+
public class DuplicateConflictBenchmarks
20+
{
21+
[Params(10, 100)]
22+
public int Iterations { get; set; }
23+
24+
[Benchmark]
25+
public int DuplicateCommitIdPath()
26+
{
27+
var persistence = CreatePersistence();
28+
persistence.Purge();
29+
30+
var duplicateCount = 0;
31+
for (int i = 0; i < Iterations; i++)
32+
{
33+
var streamId = Guid.NewGuid().ToString("N");
34+
var duplicateCommitId = Guid.NewGuid();
35+
36+
persistence.Commit(CreateAttempt(streamId, streamRevision: 1, commitSequence: 1, duplicateCommitId, i));
37+
38+
try
39+
{
40+
persistence.Commit(CreateAttempt(streamId, streamRevision: 2, commitSequence: 2, duplicateCommitId, i + 1));
41+
}
42+
catch (DuplicateCommitException)
43+
{
44+
duplicateCount++;
45+
}
46+
}
47+
48+
return duplicateCount;
49+
}
50+
51+
[Benchmark]
52+
public int DuplicateCheckpointRetryPath()
53+
{
54+
var options = new MongoPersistenceOptions();
55+
var db = options.ConnectToDatabase(EventStoreHelpers.GetConnectionString());
56+
var collection = db.GetCollection<BsonDocument>("Commits");
57+
options.CheckpointGenerator = new OneDuplicateCheckpointGenerator(collection);
58+
59+
var persistence = CreatePersistence(options);
60+
persistence.Purge();
61+
62+
var successfulCommits = 0;
63+
for (int i = 0; i < Iterations; i++)
64+
{
65+
// Seed checkpoint 1 so the next commit first attempt conflicts and forces SignalDuplicateId/Next retry.
66+
var seedStreamId = $"seed-{i}";
67+
persistence.Commit(CreateAttempt(seedStreamId, streamRevision: 1, commitSequence: 1, Guid.NewGuid(), i));
68+
69+
var writeStreamId = $"retry-{i}";
70+
var commit = persistence.Commit(CreateAttempt(writeStreamId, streamRevision: 1, commitSequence: 1, Guid.NewGuid(), i + 1));
71+
if (commit != null)
72+
{
73+
successfulCommits++;
74+
}
75+
}
76+
77+
return successfulCommits;
78+
}
79+
80+
private static IPersistStreams CreatePersistence(MongoPersistenceOptions? options = null)
81+
{
82+
var eventStore = EventStoreHelpers.WireupEventStore(options);
83+
return (IPersistStreams)eventStore.Advanced;
84+
}
85+
86+
private static CommitAttempt CreateAttempt(string streamId, int streamRevision, int commitSequence, Guid commitId, int value)
87+
{
88+
return new CommitAttempt(
89+
bucketId: Bucket.Default,
90+
streamId: streamId,
91+
streamRevision: streamRevision,
92+
commitId: commitId,
93+
commitSequence: commitSequence,
94+
commitStamp: DateTime.UtcNow,
95+
headers: null,
96+
events: new List<EventMessage>
97+
{
98+
new EventMessage { Body = new SomeDomainEvent { Value = value.ToString() } }
99+
}
100+
);
101+
}
102+
103+
private sealed class OneDuplicateCheckpointGenerator : ICheckpointGenerator
104+
{
105+
private readonly InMemoryCheckpointGenerator _inner;
106+
private bool _shouldDuplicateOnNext = true;
107+
108+
public OneDuplicateCheckpointGenerator(IMongoCollection<BsonDocument> collection)
109+
{
110+
_inner = new InMemoryCheckpointGenerator(collection);
111+
}
112+
113+
public long Next()
114+
{
115+
if (_shouldDuplicateOnNext)
116+
{
117+
_shouldDuplicateOnNext = false;
118+
return 1;
119+
}
120+
121+
return _inner.Next();
122+
}
123+
124+
public async System.Threading.Tasks.Task<long> NextAsync(System.Threading.CancellationToken cancellationToken = default)
125+
{
126+
if (_shouldDuplicateOnNext)
127+
{
128+
_shouldDuplicateOnNext = false;
129+
return 1;
130+
}
131+
132+
return await _inner.NextAsync(cancellationToken).ConfigureAwait(false);
133+
}
134+
135+
public void SignalDuplicateId(long id)
136+
{
137+
_inner.SignalDuplicateId(id);
138+
_shouldDuplicateOnNext = false;
139+
}
140+
141+
public async System.Threading.Tasks.Task SignalDuplicateIdAsync(long id, System.Threading.CancellationToken cancellationToken = default)
142+
{
143+
await _inner.SignalDuplicateIdAsync(id, cancellationToken).ConfigureAwait(false);
144+
_shouldDuplicateOnNext = false;
145+
}
146+
}
147+
}
148+
}

0 commit comments

Comments
 (0)