Skip to content

Commit 09f0921

Browse files
committed
Refactor benchmark configurations to remove invocation count for improved clarity
1 parent fecaa46 commit 09f0921

5 files changed

Lines changed: 105 additions & 21 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ dotnet test ./src/NEventStore.Persistence.MongoDB.Core.sln -c Release --no-build
105105
```
106106

107107
### CI/CD & Versioning
108-
- **AppVeyor** runs tests on Windows with MongoDB service enabled; see `appveyor.yml`
108+
- **GitHub Actions** build on Windows machines and run tests on MongoDB on linux.
109109
- **GitVersion** auto-patches assembly info from Git tags (do NOT manually edit version metadata)
110110
- **GitFlow workflow**: `release/*` and `hotfix/*` branches lock version increments
111111
- **NuGet packaging**: `nuget pack ./src/.nuget/NEventStore.Persistence.MongoDB.nuspec` outputs symbols package

Changelog.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
- Added explicit support for net8.0, net9.0, net10.0.
66
- Updated NEventStore to 10.2.0
77
- Performance: per-stream read queries require in-memory sort (CheckpointNumber not in index) [#73](https://github.com/NEventStore/NEventStore.Persistence.MongoDB/issues/73)
8+
- Some code cleanup and refactoring.
89

910
### Breaking Changes
1011

docs/Performance-Investigation.md

Lines changed: 101 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,30 @@ This is the current state after reviewing `docs/Performance-Investigation.md`, l
2727
- All-buckets checkpoint reads are not a COLLSCAN regression.
2828
- MongoDB chooses `_id_`, not `GetFrom_Checkpoint_Index`.
2929
- A partial `_id` index is not viable because MongoDB rejects `partialFilterExpression` on `_id`.
30+
- Issue [#76](https://github.com/NEventStore/NEventStore.Persistence.MongoDB/issues/76) is measured and on standby:
31+
- `InMemoryCheckpointGenerator` is materially faster for writes.
32+
- Do not change the default because the current default preserves no-hole behavior after concurrency exceptions.
33+
- Treat `InMemoryCheckpointGenerator` as an explicit opt-in tuning option only.
34+
- Issue [#77](https://github.com/NEventStore/NEventStore.Persistence.MongoDB/issues/77) is investigated and on standby:
35+
- The successful commit path does re-materialize the inserted BSON document via `commitDoc.ToCommit(_serializer)`.
36+
- The obvious shortcut would return original `CommitAttempt` event messages instead of serializer round-tripped messages.
37+
- Do not change without a dedicated microbenchmark and return-contract tests.
38+
- Issue [#78](https://github.com/NEventStore/NEventStore.Persistence.MongoDB/issues/78) is measured and on standby:
39+
- Background stream-head updates did not show a clear repeatable wall-clock win.
40+
- No engine change is justified from the current benchmark data.
3041
- Current benchmark slices cover the known hotspots: stream reads, global reads, checkpoint generator choice, snapshot/background updates, duplicate conflicts, recycle-bin reads, sync writes, and async writes.
3142

43+
### Issue decision summary
44+
45+
| Issue | Decision | Follow-up trigger |
46+
|---|---|---|
47+
| [#73](https://github.com/NEventStore/NEventStore.Persistence.MongoDB/issues/73) | Closed by decision. Keep the stream-read sort fix and explain-plan evidence. | Revisit only if a future benchmark or explain audit contradicts the current index-plan result. |
48+
| [#74](https://github.com/NEventStore/NEventStore.Persistence.MongoDB/issues/74) | Closed by decision. Keep eager `ToCommit` materialization simple. | Revisit only with a simple, clearly measurable read-path win. |
49+
| [#75](https://github.com/NEventStore/NEventStore.Persistence.MongoDB/issues/75) | Standby. Not a rebuild bottleneck and not a COLLSCAN regression. | Revisit only for recycle-bin-heavy all-buckets polling workloads. |
50+
| [#76](https://github.com/NEventStore/NEventStore.Persistence.MongoDB/issues/76) | Standby/opt-in. `InMemoryCheckpointGenerator` is faster but changes checkpoint-hole behavior. | Document or expose guidance for callers that explicitly accept holes for write throughput. |
51+
| [#77](https://github.com/NEventStore/NEventStore.Persistence.MongoDB/issues/77) | Standby. Return-path shortcut is observable and needs tighter tests. | Build a microbenchmark for `commitDoc.ToCommit(_serializer)` and return materialization contract tests. |
52+
| [#78](https://github.com/NEventStore/NEventStore.Persistence.MongoDB/issues/78) | Standby. Background stream-head update comparison is noisy and no clear win. | Revisit only if write throughput is the target and a stronger benchmark isolates this path. |
53+
3254
### Conflicts to resolve
3355

3456
- Canonical rebuild-focused benchmark snapshots now exist for `ReadFromStreamBenchmarks`, `StreamRevisionWindowBenchmarks`, and `SnapshotAssistedRebuildBenchmarks`.
@@ -51,7 +73,9 @@ Current implications:
5173
- #73 is the main completed rebuild optimization: per-stream reads now sort by `StreamRevisionFrom`, matching `GetFrom_Index` and avoiding the old checkpoint-sort plan.
5274
- #74 is intentionally closed: eager `ToCommit` materialization remains simple because attempted optimizations were too complex for negligible gain.
5375
- #75 is not a rebuild bottleneck. It affects all-buckets checkpoint polling, so it stays in standby.
54-
- #76 and #78 are write-path issues. Keep them behind rebuild-focused read work unless write throughput becomes the target again.
76+
- #76 is a write-path opt-in trade-off, not a default change: `InMemoryCheckpointGenerator` is faster but can leave checkpoint holes after concurrency exceptions.
77+
- #77 is a write-path standby item. It needs a dedicated microbenchmark and return-contract tests before any engine change.
78+
- #78 is a write-path standby item. Keep it behind rebuild-focused read work unless write throughput becomes the target again.
5579

5680
What matters next for rebuilds:
5781

@@ -67,16 +91,17 @@ What matters next for rebuilds:
6791
| 1 | Rebuild benchmark evidence | Done for current pass | Canonical rebuild snapshots are archived for full stream, tail-window, and snapshot-assisted rebuild reads. |
6892
| 2 | [#73](https://github.com/NEventStore/NEventStore.Persistence.MongoDB/issues/73) stream-read sort | Closed by decision | Keep the explain-plan evidence as the reason for closure. Do not claim a wall-clock benchmark win from the current data. |
6993
| 3 | [#75](https://github.com/NEventStore/NEventStore.Persistence.MongoDB/issues/75) all-buckets checkpoint scan | Standby after investigation | Not a rebuild bottleneck. Resume only if all-buckets polling with a large recycle bin becomes a target workload. |
70-
| 4 | [#76](https://github.com/NEventStore/NEventStore.Persistence.MongoDB/issues/76) checkpoint generator DB read | Open, write-path | Defer while rebuild reads are the priority. |
71-
| 5 | [#78](https://github.com/NEventStore/NEventStore.Persistence.MongoDB/issues/78) stream-head background updates | Open, write-path | Defer while rebuild reads are the priority. |
72-
| 6 | [#77](https://github.com/NEventStore/NEventStore.Persistence.MongoDB/issues/77) post-insert re-deserialization | Open, write-path | Defer unless write throughput becomes the target again. |
94+
| 4 | [#76](https://github.com/NEventStore/NEventStore.Persistence.MongoDB/issues/76) checkpoint generator DB read | Measured, write-path opt-in | Stabilized benchmark confirms `InMemoryCheckpointGenerator` is faster, but it changes checkpoint-hole behavior. Do not change the default. |
95+
| 5 | [#78](https://github.com/NEventStore/NEventStore.Persistence.MongoDB/issues/78) stream-head background updates | Measured, write-path standby | Stabilized snapshot-overhead slice is archived. No engine change yet because background on/off remains noisy and shows no clear win. |
96+
| 6 | [#77](https://github.com/NEventStore/NEventStore.Persistence.MongoDB/issues/77) post-insert re-deserialization | Investigated, write-path standby | Do not change without a dedicated microbenchmark and return-contract tests. Avoid returning the original `CommitAttempt` events unless serializer round-trip semantics are proven unnecessary. |
7397

7498
### Next recommended pass
7599

76100
1. Rerun the rebuild snapshots with the stabilized benchmark job before comparing future read-heavy changes.
77101
2. Use the archived rebuild snapshots as historical evidence for the #73 pass, not as the final stable baseline.
78102
3. Keep engine code simple unless a future benchmark shows a clear, repeatable rebuild win.
79103
4. Revisit #75/#76/#78/#77 only if the target workload changes away from rebuild reads.
104+
5. For write-side work, treat #76 as an opt-in tuning option, #77 as contract-sensitive standby, and #78 as measured/standby.
80105

81106
## Current Benchmark Status
82107

@@ -221,7 +246,7 @@ Current recommendation:
221246

222247
### 4. Default checkpoint generation adds a database read per commit
223248

224-
**Write-side secondary.** `MongoPersistenceEngine.Initialize()` defaults to `AlwaysQueryDbForNextValueCheckpointGenerator`.
249+
**Write-side opt-in trade-off.** `MongoPersistenceEngine.Initialize()` defaults to `AlwaysQueryDbForNextValueCheckpointGenerator`.
225250

226251
That generator calls `GetLastValue()` on every `Next()` invocation, which means one extra database read for every commit before the insert even happens.
227252

@@ -231,14 +256,15 @@ Why this matters:
231256
- However, read-side deserialization cost typically dominates for mixed workloads.
232257
- The cost scales directly with commit volume.
233258

234-
What needs benchmarking first:
235-
236-
- Current default generator vs `InMemoryCheckpointGenerator` behavior (already in benchmark suite).
237-
- Confirm this is secondary to read-path overhead.
238-
239-
### 5. Commit success path re-deserializes the inserted document
259+
What needs benchmarking first:
260+
261+
- Completed: current default generator vs `InMemoryCheckpointGenerator` behavior is covered by `CheckpointGeneratorBenchmarks`.
262+
- Do not change the default generator without a breaking-change decision, because acceptance tests cover the current no-hole default after concurrency exceptions.
263+
- Use `InMemoryCheckpointGenerator` only as an explicit write-throughput tuning option for callers that accept checkpoint holes after concurrency conflicts.
240264

241-
**Write-side lower-priority.** After a successful insert, `Commit()` returns `commitDoc.ToCommit(_serializer)`.
265+
### 5. Commit success path re-deserializes the inserted document
266+
267+
**Write-side standby.** After a successful insert, `Commit()` returns `commitDoc.ToCommit(_serializer)`.
242268

243269
That means the write path serializes the commit to BSON for insert, then immediately deserializes the same BSON back to `ICommit` to return it.
244270

@@ -248,9 +274,18 @@ Why this matters:
248274
- The cost is paid on every successful commit, but write insertion is typically not the loop bottleneck.
249275
- It is independent of MongoDB round-trip latency.
250276

251-
What needs benchmarking first:
252-
253-
- Compare this cost in the context of total write-path time (it may be noise).
277+
Follow-up investigation:
278+
279+
- Sync and async commit paths both return `commitDoc.ToCommit(_serializer)` after successful insert.
280+
- The obvious shortcut is constructing `Commit` directly from `CommitAttempt` plus the generated checkpoint, but that would return the caller's event messages rather than the serializer round-tripped event messages currently produced by `ToCommit`.
281+
- Commit hooks receive the returned `ICommit`, so this is observable behavior, not only an internal allocation detail.
282+
- A stabilized full `WriteToStreamBenchmarks` run was attempted for this slice, but the 10,000-commit case exceeded the command timeout and produced no usable archive.
283+
284+
Current recommendation:
285+
286+
- Keep #77 in standby.
287+
- Do not change the runtime commit return path without a dedicated microbenchmark that isolates `commitDoc.ToCommit(_serializer)` and tests that lock down acceptable return materialization semantics.
288+
- If write throughput becomes the target again, prefer a narrow benchmark that compares `commitDoc.ToCommit(_serializer)` against a candidate `CommitAttempt`-based materializer before touching `MongoPersistenceEngine.Commit`.
254289

255290
### 6. Stream-head updates schedule background work per commit
256291

@@ -304,7 +339,7 @@ The next performance pass should focus on aggregate rebuild reads.
304339

305340
1. Compare future read-heavy changes against the stabilized rebuild snapshots.
306341
2. If `SnapshotAssistedRebuild(10000, 1000)` remains noisy in future runs, isolate it and rerun before using its mean in a decision.
307-
3. Keep #75 in standby and defer #76/#78/#77 unless write throughput or all-buckets polling becomes the target again.
342+
3. Keep #75 in standby, treat #76 as an opt-in write tuning option, and defer #78/#77 unless write throughput becomes the target again.
308343

309344
## Rebuild Benchmark Snapshot
310345

@@ -351,8 +386,56 @@ The allocation shape is the useful signal here: allocations track the returned w
351386
| 10,000 | 1,000 | 205.723 ms | 52.314 ms | 69,760.77 KB | 6,994.45 KB |
352387

353388
Snapshot-assisted rebuilds materially reduce work when the snapshot is near the tail. For 10,000-commit streams, allocations drop from about 68 MB for full rebuilds to about 0.1 MB, 0.7 MB, or 6.8 MB depending on the number of commits after the snapshot. The `10,000 / 1,000` snapshot-assisted timing had high variance in this run (`52.314 ms` mean, `43.982 ms` stddev), but its allocation reduction is still clear.
354-
355-
## Artifacts From This Investigation
389+
390+
## Write-Side Snapshot-Overhead Snapshot
391+
392+
Issue [#78](https://github.com/NEventStore/NEventStore.Persistence.MongoDB/issues/78) was rechecked on 2026-06-10 with `SnapshotOverheadBenchmarks` after removing the pinned `InvocationCount=1` from the benchmark job. BenchmarkDotNet now selects invocation counts through its pilot phase for this slice too.
393+
394+
Archived raw output:
395+
396+
- `artifacts/benchmark-snapshots/benchmark-after-write-snapshot-overhead-stabilized-net10.0-20260610-1712.zip`
397+
398+
| Commits | Snapshot support disabled | Background stream-head update | Mean | StdDev | Allocated |
399+
|---:|---|---|---:|---:|---:|
400+
| 100 | false | false | 206.2 ms | 47.78 ms | 22.69 MB |
401+
| 100 | false | true | 220.3 ms | 140.86 ms | 22.70 MB |
402+
| 100 | true | false | 149.2 ms | 16.86 ms | 28.16 MB |
403+
| 100 | true | true | 140.1 ms | 4.12 ms | 21.08 MB |
404+
| 1,000 | false | false | 1,724.0 ms | 362.08 ms | 110.82 MB |
405+
| 1,000 | false | true | 1,676.5 ms | 477.40 ms | 110.96 MB |
406+
| 1,000 | true | false | 1,623.6 ms | 366.49 ms | 94.77 MB |
407+
| 1,000 | true | true | 1,954.2 ms | 419.49 ms | 94.74 MB |
408+
409+
Current decision:
410+
411+
- Background stream-head updates do not show a clear repeatable wall-clock win.
412+
- With snapshot support enabled, 1,000-commit allocation remains about 111 MB regardless of background mode.
413+
- Disabling snapshot support lowers 1,000-commit allocation to about 95 MB, but that changes behavior and only applies to callers that do not need snapshots.
414+
- Keep #78 in standby. Do not replace the current simple per-commit background update path without a stronger write-throughput benchmark signal.
415+
416+
## Write-Side Checkpoint-Generator Snapshot
417+
418+
Issue [#76](https://github.com/NEventStore/NEventStore.Persistence.MongoDB/issues/76) was rechecked on 2026-06-10 with `CheckpointGeneratorBenchmarks` after removing the pinned `InvocationCount=1` from the benchmark job. BenchmarkDotNet now selects invocation counts through its pilot phase for this slice too.
419+
420+
Archived raw output:
421+
422+
- `artifacts/benchmark-snapshots/benchmark-after-write-checkpoint-generator-stabilized-net10.0-20260610-1723.zip`
423+
424+
| Commits | Generator | Mean | StdDev | Allocated |
425+
|---:|---|---:|---:|---:|
426+
| 100 | Always | 244.3 ms | 14.29 ms | 16.88 MB |
427+
| 100 | InMemory | 147.7 ms | 6.87 ms | 28.24 MB |
428+
| 1,000 | Always | 2,506.2 ms | 238.76 ms | 110.96 MB |
429+
| 1,000 | InMemory | 1,322.9 ms | 274.52 ms | 95.54 MB |
430+
431+
Current decision:
432+
433+
- `InMemoryCheckpointGenerator` is materially faster for write throughput in this benchmark.
434+
- The default `AlwaysQueryDbForNextValueCheckpointGenerator` should not be changed in this pass because it preserves the current no-hole default after concurrency exceptions.
435+
- `InMemoryCheckpointGenerator` remains the safe performance path only as explicit caller configuration when checkpoint holes after concurrency conflicts are acceptable.
436+
- Keep #76 as measured/opt-in rather than a runtime engine change.
437+
438+
## Artifacts From This Investigation
356439

357440
BenchmarkDotNet outputs reports under `BenchmarkDotNet.Artifacts/results/` for each selected benchmark class.
358441

src/NEventStore.Persistence.MongoDB.Benchmark/Benchmarks/CheckpointGeneratorBenchmarks.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ namespace NEventStore.Persistence.MongoDB.Benchmark.Benchmarks
1313
/// - "InMemory": InMemoryCheckpointGenerator — in-memory increment; DB only on duplicate signal.
1414
/// </summary>
1515
[Config(typeof(AllowNonOptimized))]
16-
[SimpleJob(launchCount: 3, warmupCount: 3, iterationCount: 3, invocationCount: 1)]
16+
[SimpleJob(launchCount: 3, warmupCount: 3, iterationCount: 3)]
1717
[MemoryDiagnoser]
1818
[MeanColumn, StdErrorColumn, StdDevColumn, MinColumn, MaxColumn, IterationsColumn]
1919
public class CheckpointGeneratorBenchmarks

src/NEventStore.Persistence.MongoDB.Benchmark/Benchmarks/SnapshotOverheadBenchmarks.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ namespace NEventStore.Persistence.MongoDB.Benchmark.Benchmarks
1919
/// </para>
2020
/// </summary>
2121
[Config(typeof(AllowNonOptimized))]
22-
[SimpleJob(launchCount: 3, warmupCount: 3, iterationCount: 3, invocationCount: 1)]
22+
[SimpleJob(launchCount: 3, warmupCount: 3, iterationCount: 3)]
2323
[MemoryDiagnoser]
2424
[MeanColumn, StdErrorColumn, StdDevColumn, MinColumn, MaxColumn, IterationsColumn]
2525
public class SnapshotOverheadBenchmarks

0 commit comments

Comments
 (0)