diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index fdf7ee4..ff39803 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -42,11 +42,11 @@ jobs:
uses: actions/cache@v6
with:
path: ~/.nuget/packages
- key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj', '**/Directory.*.props', 'global.json') }}
+ key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj', '**/Directory.*.props', '**/packages.lock.json', 'global.json') }}
restore-keys: nuget-${{ runner.os }}-
- name: Restore
- run: dotnet restore
+ run: dotnet restore -p:RestoreLockedMode=true
- name: Build
run: dotnet build --no-restore -c Release
diff --git a/.gitignore b/.gitignore
index 51b4c07..06e30f3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,7 +1,5 @@
-# .gitignore — RequestFlow
-# Modern SDK-style .NET library solution (.slnx), multi-target
-# netstandard2.0;net8.0;net10.0. NuGet-published. No project.json /
-# packages.config legacy cruft on purpose.
+# RequestFlow: SDK-style .NET library solution (.slnx), NuGet-published.
+# No project.json / packages.config legacy cruft on purpose.
# ── Build output ─────────────────────────────────────────────
[Bb]in/
@@ -24,17 +22,13 @@ artifacts/
!.vscode/launch.json
!.vscode/extensions.json
-# ── Local build settings (per-machine) ───────────────────────
-# Optional override file. Directory.Build.props imports it when it exists.
-Directory.Local.props
-
# ── NuGet package output ─────────────────────────────────────
# Packing RequestFlow / RequestFlow.Cqrs. Symbols too (.snupkg).
*.nupkg
*.snupkg
# ── Test results & coverage ──────────────────────────────────
-# Unit test project + coverage (v1 checklist #7).
+# Unit test project + coverage.
[Tt]est[Rr]esults/
*.trx
*.coverage
@@ -44,7 +38,7 @@ coverage*.xml
coverage*.info
# ── BenchmarkDotNet ──────────────────────────────────────────
-# Dispatch-cost benchmarks vs MediatR (plan §"Targets and quality bar").
+# Dispatch-cost benchmarks vs MediatR.
BenchmarkDotNet.Artifacts/
# ── MSBuild / binary logs ────────────────────────────────────
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0493ef3..0282210 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,20 @@ Pre-1.0: the public API can still change between previews.
The `release` workflow uses the section matching the pushed tag as the GitHub Release body. A missing section fails the build before publishing. Before tagging, rename `[Unreleased]` to the release version and add its date.
+## [Unreleased]
+
+### Added
+
+- [Benchmark suites](benchmarks/README.md) on .NET 10. [`RequestFlow.Benchmarks.MediatR`](benchmarks/RequestFlow.Benchmarks.MediatR/README.md) compares requests, void requests, streams, events, catalog size, mixed traffic, and short-circuit pipelines against MediatR 12.5.0, the last Apache-2.0 release. `RequestFlow.Benchmarks` compares RequestFlow's Task and ValueTask paths and stage chains.
+
+### Changed
+
+- The MediatR notes in [events.md](docs/events.md#notes-for-mediatr-migrations) describe MediatR 12.5.0 probes only.
+
+### Performance
+
+- Dispatch allocates less for a stream sent without a cancelable token and for a `ParallelPublishStrategy` event with one applicable handler. Exception types, parameter names, and synchronous throws are unchanged.
+
## [1.0.0-preview.9] - 2026-09-08
### Added
diff --git a/Directory.Build.props b/Directory.Build.props
index 10a6d7f..637e008 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -23,7 +23,4 @@
-
-
-
diff --git a/README.md b/README.md
index 551c3af..98cca68 100644
--- a/README.md
+++ b/README.md
@@ -88,6 +88,10 @@ Void handlers return plain `Task`; `Unit` does not appear in user code. Existing
The [MediatR migration guide](https://github.com/illia1f/RequestFlow/blob/main/docs/migrating-from-mediatr.md) covers the file-by-file sequence, event differences, conditional registration, and unsupported extension points.
+## Benchmarks
+
+The [MediatR comparison](https://github.com/illia1f/RequestFlow/blob/main/benchmarks/RequestFlow.Benchmarks.MediatR/README.md) measures requests, stages, streams, events, and short-circuit paths against MediatR 12.5.0 on .NET 10, with correctness probes and package lock files. Its results describe MediatR 12.5.0 only, not later releases. The [benchmark guide](https://github.com/illia1f/RequestFlow/blob/main/benchmarks/README.md) explains how to run the suites.
+
## Packages
- **[`RequestFlow.Abstractions`](https://www.nuget.org/packages/RequestFlow.Abstractions)** holds Task and ValueTask requests, handlers, dispatchers, stages, streaming, events, publish strategies, validation models, and exceptions. It has no package dependency on `net8.0` or `net10.0`.
diff --git a/ROADMAP.md b/ROADMAP.md
index 36ca6e9..21562d7 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -22,7 +22,7 @@ Release: [1.0.0-preview.9](CHANGELOG.md#100-preview9---2026-09-08).
- [x] Streaming requests via `IAsyncEnumerable`
- [x] Opt-in ValueTask request family: `IValueRequest`, ValueTask stages, and CQRS command/query twins
- [x] MediatR migration and target compatibility guides
-- [ ] BenchmarkDotNet suite against pinned MediatR, martinothamar/Mediator, LiteBus, and DispatchR packages, with raw results
+- [ ] BenchmarkDotNet suite against pinned MediatR, martinothamar/Mediator, and DispatchR packages, with raw results
Targets: `netstandard2.0;net462;net8.0;net10.0`. The `net462` target has shipped since `1.0.0-preview.2`.
diff --git a/RequestFlow.slnx b/RequestFlow.slnx
index 56ff1cb..e8fb28d 100644
--- a/RequestFlow.slnx
+++ b/RequestFlow.slnx
@@ -1,4 +1,12 @@
+
+
+
+
+
+
+
+
diff --git a/benchmarks/Directory.Build.props b/benchmarks/Directory.Build.props
new file mode 100644
index 0000000..0089dcf
--- /dev/null
+++ b/benchmarks/Directory.Build.props
@@ -0,0 +1,11 @@
+
+
+
+ false
+ false
+ true
+
+
+
+
+
diff --git a/benchmarks/Directory.Build.targets b/benchmarks/Directory.Build.targets
new file mode 100644
index 0000000..39767e8
--- /dev/null
+++ b/benchmarks/Directory.Build.targets
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
diff --git a/benchmarks/README.md b/benchmarks/README.md
new file mode 100644
index 0000000..f68971e
--- /dev/null
+++ b/benchmarks/README.md
@@ -0,0 +1,139 @@
+# Benchmarks
+
+Two BenchmarkDotNet suites, both on .NET 10:
+
+| Suite | Cases | Measures |
+| --- | --- | --- |
+| `RequestFlow.Benchmarks.MediatR` | 224 | RequestFlow against MediatR 12.5.0 in one executable, with matching workloads and service lifetimes. See the [MediatR comparison](RequestFlow.Benchmarks.MediatR/README.md). |
+| `RequestFlow.Benchmarks` | 34 | RequestFlow alone: Task vs ValueTask paths and stage chains. |
+
+- A case is one benchmark method with one parameter set.
+- No timing results are published yet.
+- RequestFlow is built from the checked-out source, so record the commit when sharing results.
+- BenchmarkDotNet 0.15.8 and Microsoft DI 10.0.9. Package lock files pin the resolved dependencies.
+- CI builds both suites but does not run them.
+
+## Quick start
+
+Requires the .NET 10 SDK. Run from the repository root:
+
+```powershell
+dotnet restore benchmarks/RequestFlow.Benchmarks.MediatR -p:RestoreLockedMode=true
+dotnet build benchmarks/RequestFlow.Benchmarks.MediatR -c Release --no-restore
+
+# Check results, execution order, lifetimes, tokens, and disposal without timing.
+dotnet run --project benchmarks/RequestFlow.Benchmarks.MediatR -c Release -f net10.0 --no-build --no-restore -- probe
+
+# Rough timings for one group. Not publishable.
+dotnet run --project benchmarks/RequestFlow.Benchmarks.MediatR -c Release -f net10.0 --no-build --no-restore -- --quick --filter '*TaskRetainedScopeBenchmarks*'
+
+# Full run of every case into an unused directory.
+dotnet run --project benchmarks/RequestFlow.Benchmarks.MediatR -c Release -f net10.0 --no-build --no-restore -- --filter '*' --artifacts benchmarks/artifacts/YYYY-MM-DD-mediatr-full
+```
+
+- These commands build only the suite and the `net10.0` build of RequestFlow. To build the whole solution, run `dotnet restore -p:RestoreLockedMode=true` and `dotnet build -c Release --no-restore`.
+- Single-quoted filters work in PowerShell and bash. In cmd.exe, use double quotes.
+
+## RequestFlow suite
+
+```powershell
+dotnet restore benchmarks/RequestFlow.Benchmarks -p:RestoreLockedMode=true
+dotnet build benchmarks/RequestFlow.Benchmarks -c Release --no-restore
+
+# Verify the workloads without timing.
+dotnet run --project benchmarks/RequestFlow.Benchmarks -c Release -f net10.0 --no-build --no-restore -- probe
+
+# Rough timings for Task and ValueTask void dispatch.
+dotnet run --project benchmarks/RequestFlow.Benchmarks -c Release -f net10.0 --no-build --no-restore -- --quick --filter '*VoidCompletionBenchmarks*'
+```
+
+- For a full run, use `--filter '*'` without `--quick`.
+- Each class uses the Task path as the baseline for the ValueTask path.
+- `ChainLevelBenchmarks` bypasses the dispatcher. It enters the Task, ValueTask, and void plans that `AddRequestFlow` froze with 0, 1, or 5 stages.
+- Void completion and short-circuit benchmarks dispatch through RequestFlow.
+- Rows use the shared fixture handlers and stages, so the handler work matches the comparison suite.
+
+## Run modes
+
+| Argument | What it does |
+| --- | --- |
+| `probe` | Checks every workload without timing. A pass ends with `Probe passed:` and the number of classes, parameter sets, and cases checked. |
+| `--smoke` | Runs each case once to check it executes. |
+| `--quick` | Rough timings. Not publishable. |
+| `--filter` without `--smoke` or `--quick` | Runs `probe`, then the full profile: two launches, 15 to 50 warm-up and 60 to 100 measured iterations, 5% relative error target. |
+
+- Without arguments, `dotnet run` applies the MediatR suite's first launch profile, **Verify comparison**, so it only runs `probe`.
+- The RequestFlow suite has no launch profile. Without arguments it runs `probe`, then shows BenchmarkDotNet's selection prompt.
+
+## Filters
+
+- `--filter '*'` selects every case.
+- When narrowing, keep both libraries selected within each scenario.
+- Pass several patterns to one `--filter` to combine scenarios: `--filter '*MediatRStream*' '*MediatRVoidRequest*'`.
+
+| MediatR scenario | Filter | Cases |
+| --- | --- | --- |
+| Task requests | `*MediatRTask*Scope*` | 36 |
+| Void requests | `*MediatRVoidRequest*` | 24 |
+| Streams | `*MediatRStream*` | 72 |
+| Sequential events | `*MediatREventSequential*` | 16 |
+| Parallel events | `*MediatREventParallel*` | 16 |
+| Catalog size | `*MediatRCatalogSize*` | 18 |
+| Mixed traffic | `*MediatRMixedTraffic*` | 18 |
+| Short circuit | `*MediatRShortCircuit*` | 18 |
+| Direct-handler reference | `*MediatRTaskDirectHandler*` | 6 |
+
+## Reports
+
+- `--artifacts ` sets the output directory. Without it, reports go to `BenchmarkDotNet.Artifacts////` under the current directory, where `` is `mediatr` or `requestflow`.
+- Readable tables are the `results/*-report-github.md` files. `run-manifest.json` records the run status and host details.
+- Git ignores `BenchmarkDotNet.Artifacts/` and `benchmarks/artifacts/`.
+
+## Publishing a run
+
+Publish only a run whose `run-manifest.json` shows `status: completed` and `publicationCandidate: true`. That requires:
+
+- the full profile, with no measurement overrides
+- a passing probe
+- Git can read HEAD and the status of every build input
+- a clean Git status for every build input, with HEAD and that status unchanged from start to end of the run
+- no `DOTNET_*`, `COMPlus_*`, or `CORECLR_*` environment variables, except the SDK and host settings the harness ignores
+
+Before the run:
+
+- Build the suite after your last commit. A full run fails before timing anything when the build's commit differs from HEAD.
+- Delete stray files under `src/`, the suite folder, and each benchmark project the suite references, such as old reports and `.csproj.user` files. The source check includes ignored files and skips only `bin` and `obj`.
+- Start without a processor affinity mask. The jobs set none, but child processes inherit any mask set with `start /affinity`, `taskset`, or a similar tool.
+- Leave the machine otherwise idle.
+
+After the run:
+
+- Check that `hostEnvironment.processorCount` in the manifest equals the logical core count in the report header. `publicationCandidate` does not check affinity, and parallel event and suspending rows depend on how many cores the process can use.
+- A run stopped with Ctrl+C stays at `status: running` and is not publishable.
+
+## Visual Studio
+
+Use Visual Studio for exploration only. Publish from the command line.
+
+1. Open `RequestFlow.slnx` in a Visual Studio version that supports the .NET 10 SDK and `.slnx` solutions.
+2. In the `benchmarks` solution folder, set `RequestFlow.Benchmarks.MediatR` as the startup project.
+3. Select the **Release** configuration.
+4. Pick a launch profile: **Verify comparison** (`probe`), **Smoke test** (`--smoke` on the Task retained-scope class only), or **Full comparison** (`--filter *`).
+5. Start without debugging (**Ctrl+F5**).
+
+- Reports go under `BenchmarkDotNet.Artifacts/mediatr/` at the repository root, because the profiles set `workingDirectory` to `../..`. `dotnet run` ignores that setting.
+- A Visual Studio run is never a publication candidate. Visual Studio saves the selected profile in `RequestFlow.Benchmarks.MediatR.csproj.user`, which the source check flags.
+
+## Projects
+
+All five are in the root solution.
+
+| Project | Contents |
+| --- | --- |
+| `RequestFlow.Benchmarks` | Stage-chain measurements, Task vs ValueTask dispatch, and probes |
+| `RequestFlow.Benchmarks.MediatR` | MediatR fixtures, paired benchmarks, and probes. Does not depend on `RequestFlow.Benchmarks`. |
+| `RequestFlow.Benchmarks.Harness` | BenchmarkDotNet configuration, runner, run manifest, and provenance records. Knows nothing about workloads. |
+| `RequestFlow.Benchmarks.Core` | Probe engine, trace names, provider hosts, shared workloads, and the short-circuit dependency graph |
+| `RequestFlow.Benchmarks.RequestFlowFixtures` | RequestFlow handlers, stages, registration, and message catalogs |
+
+- Benchmark classes are public and inheritable so BenchmarkDotNet can generate its harness.
diff --git a/benchmarks/RequestFlow.Benchmarks.Core/Catalog/RuntimeCatalog.cs b/benchmarks/RequestFlow.Benchmarks.Core/Catalog/RuntimeCatalog.cs
new file mode 100644
index 0000000..0dc5632
--- /dev/null
+++ b/benchmarks/RequestFlow.Benchmarks.Core/Catalog/RuntimeCatalog.cs
@@ -0,0 +1,172 @@
+namespace RequestFlow.Benchmarks.Core;
+
+public static class RuntimeCatalog
+{
+ ///
+ /// The number of sends in one mixed-traffic invocation.
+ ///
+ public const int MixedTrafficBatch = 64;
+
+ private const int MixedTrafficSeed = 20260926;
+
+ private static readonly int[] Sizes = [4, 16, 64];
+
+ public static Type[] Tags(int count)
+ {
+ if (!Sizes.Contains(count))
+ throw new ArgumentOutOfRangeException(nameof(count));
+
+ return AllTags.Take(count).ToArray();
+ }
+
+ ///
+ /// Returns the tag of every send in one mixed-traffic invocation over a catalog of types.
+ /// Each tag appears equally often, in an order shuffled once with a fixed seed.
+ ///
+ public static IReadOnlyList MixedTrafficOrder(int count)
+ => MixedTrafficOrders.TryGetValue(count, out Type[]? order) ? order : throw new ArgumentOutOfRangeException(nameof(count));
+
+ private static readonly Type[] AllTags =
+ [
+ typeof(RuntimeTag1),
+ typeof(RuntimeTag2),
+ typeof(RuntimeTag3),
+ typeof(RuntimeTag4),
+ typeof(RuntimeTag5),
+ typeof(RuntimeTag6),
+ typeof(RuntimeTag7),
+ typeof(RuntimeTag8),
+ typeof(RuntimeTag9),
+ typeof(RuntimeTag10),
+ typeof(RuntimeTag11),
+ typeof(RuntimeTag12),
+ typeof(RuntimeTag13),
+ typeof(RuntimeTag14),
+ typeof(RuntimeTag15),
+ typeof(RuntimeTag16),
+ typeof(RuntimeTag17),
+ typeof(RuntimeTag18),
+ typeof(RuntimeTag19),
+ typeof(RuntimeTag20),
+ typeof(RuntimeTag21),
+ typeof(RuntimeTag22),
+ typeof(RuntimeTag23),
+ typeof(RuntimeTag24),
+ typeof(RuntimeTag25),
+ typeof(RuntimeTag26),
+ typeof(RuntimeTag27),
+ typeof(RuntimeTag28),
+ typeof(RuntimeTag29),
+ typeof(RuntimeTag30),
+ typeof(RuntimeTag31),
+ typeof(RuntimeTag32),
+ typeof(RuntimeTag33),
+ typeof(RuntimeTag34),
+ typeof(RuntimeTag35),
+ typeof(RuntimeTag36),
+ typeof(RuntimeTag37),
+ typeof(RuntimeTag38),
+ typeof(RuntimeTag39),
+ typeof(RuntimeTag40),
+ typeof(RuntimeTag41),
+ typeof(RuntimeTag42),
+ typeof(RuntimeTag43),
+ typeof(RuntimeTag44),
+ typeof(RuntimeTag45),
+ typeof(RuntimeTag46),
+ typeof(RuntimeTag47),
+ typeof(RuntimeTag48),
+ typeof(RuntimeTag49),
+ typeof(RuntimeTag50),
+ typeof(RuntimeTag51),
+ typeof(RuntimeTag52),
+ typeof(RuntimeTag53),
+ typeof(RuntimeTag54),
+ typeof(RuntimeTag55),
+ typeof(RuntimeTag56),
+ typeof(RuntimeTag57),
+ typeof(RuntimeTag58),
+ typeof(RuntimeTag59),
+ typeof(RuntimeTag60),
+ typeof(RuntimeTag61),
+ typeof(RuntimeTag62),
+ typeof(RuntimeTag63),
+ typeof(RuntimeTag64),
+ ];
+
+ private static readonly Dictionary MixedTrafficOrders = Sizes.ToDictionary(size => size, CreateMixedTrafficOrder);
+
+ private static Type[] CreateMixedTrafficOrder(int count)
+ {
+ Type[] order = [.. Enumerable.Repeat(Tags(count), MixedTrafficBatch / count).SelectMany(tags => tags)];
+
+ new Random(MixedTrafficSeed).Shuffle(order);
+
+ return order;
+ }
+}
+
+public sealed class RuntimeTag1;
+public sealed class RuntimeTag2;
+public sealed class RuntimeTag3;
+public sealed class RuntimeTag4;
+public sealed class RuntimeTag5;
+public sealed class RuntimeTag6;
+public sealed class RuntimeTag7;
+public sealed class RuntimeTag8;
+public sealed class RuntimeTag9;
+public sealed class RuntimeTag10;
+public sealed class RuntimeTag11;
+public sealed class RuntimeTag12;
+public sealed class RuntimeTag13;
+public sealed class RuntimeTag14;
+public sealed class RuntimeTag15;
+public sealed class RuntimeTag16;
+public sealed class RuntimeTag17;
+public sealed class RuntimeTag18;
+public sealed class RuntimeTag19;
+public sealed class RuntimeTag20;
+public sealed class RuntimeTag21;
+public sealed class RuntimeTag22;
+public sealed class RuntimeTag23;
+public sealed class RuntimeTag24;
+public sealed class RuntimeTag25;
+public sealed class RuntimeTag26;
+public sealed class RuntimeTag27;
+public sealed class RuntimeTag28;
+public sealed class RuntimeTag29;
+public sealed class RuntimeTag30;
+public sealed class RuntimeTag31;
+public sealed class RuntimeTag32;
+public sealed class RuntimeTag33;
+public sealed class RuntimeTag34;
+public sealed class RuntimeTag35;
+public sealed class RuntimeTag36;
+public sealed class RuntimeTag37;
+public sealed class RuntimeTag38;
+public sealed class RuntimeTag39;
+public sealed class RuntimeTag40;
+public sealed class RuntimeTag41;
+public sealed class RuntimeTag42;
+public sealed class RuntimeTag43;
+public sealed class RuntimeTag44;
+public sealed class RuntimeTag45;
+public sealed class RuntimeTag46;
+public sealed class RuntimeTag47;
+public sealed class RuntimeTag48;
+public sealed class RuntimeTag49;
+public sealed class RuntimeTag50;
+public sealed class RuntimeTag51;
+public sealed class RuntimeTag52;
+public sealed class RuntimeTag53;
+public sealed class RuntimeTag54;
+public sealed class RuntimeTag55;
+public sealed class RuntimeTag56;
+public sealed class RuntimeTag57;
+public sealed class RuntimeTag58;
+public sealed class RuntimeTag59;
+public sealed class RuntimeTag60;
+public sealed class RuntimeTag61;
+public sealed class RuntimeTag62;
+public sealed class RuntimeTag63;
+public sealed class RuntimeTag64;
diff --git a/benchmarks/RequestFlow.Benchmarks.Core/Hosting/RetainedScopeHost.cs b/benchmarks/RequestFlow.Benchmarks.Core/Hosting/RetainedScopeHost.cs
new file mode 100644
index 0000000..a34a624
--- /dev/null
+++ b/benchmarks/RequestFlow.Benchmarks.Core/Hosting/RetainedScopeHost.cs
@@ -0,0 +1,31 @@
+using Microsoft.Extensions.DependencyInjection;
+
+namespace RequestFlow.Benchmarks.Core;
+
+///
+/// A provider with one retained scope and the service resolved from it.
+///
+public sealed class RetainedScopeHost : IDisposable
+ where TService : notnull
+{
+ private readonly ServiceProvider _provider;
+ private readonly IServiceScope _scope;
+
+ ///
+ /// The service resolved from the retained scope.
+ ///
+ public readonly TService Service;
+
+ public RetainedScopeHost(ServiceProvider provider)
+ {
+ _provider = provider;
+ _scope = provider.CreateScope();
+ Service = _scope.ServiceProvider.GetRequiredService();
+ }
+
+ public void Dispose()
+ {
+ _scope.Dispose();
+ _provider.Dispose();
+ }
+}
diff --git a/benchmarks/RequestFlow.Benchmarks.Core/Hosting/ScopePerOperationHost.cs b/benchmarks/RequestFlow.Benchmarks.Core/Hosting/ScopePerOperationHost.cs
new file mode 100644
index 0000000..b00aa21
--- /dev/null
+++ b/benchmarks/RequestFlow.Benchmarks.Core/Hosting/ScopePerOperationHost.cs
@@ -0,0 +1,13 @@
+using Microsoft.Extensions.DependencyInjection;
+
+namespace RequestFlow.Benchmarks.Core;
+
+///
+/// A provider whose benchmark methods create and dispose a scope per operation.
+///
+public sealed class ScopePerOperationHost(ServiceProvider provider) : IDisposable
+{
+ public readonly ServiceProvider Provider = provider;
+
+ public void Dispose() => Provider.Dispose();
+}
diff --git a/benchmarks/RequestFlow.Benchmarks.Core/RequestFlow.Benchmarks.Core.csproj b/benchmarks/RequestFlow.Benchmarks.Core/RequestFlow.Benchmarks.Core.csproj
new file mode 100644
index 0000000..175552f
--- /dev/null
+++ b/benchmarks/RequestFlow.Benchmarks.Core/RequestFlow.Benchmarks.Core.csproj
@@ -0,0 +1,18 @@
+
+
+
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/benchmarks/RequestFlow.Benchmarks.Core/ShortCircuit/DependencyComponent.cs b/benchmarks/RequestFlow.Benchmarks.Core/ShortCircuit/DependencyComponent.cs
new file mode 100644
index 0000000..1820e9c
--- /dev/null
+++ b/benchmarks/RequestFlow.Benchmarks.Core/ShortCircuit/DependencyComponent.cs
@@ -0,0 +1,28 @@
+namespace RequestFlow.Benchmarks.Core;
+
+public sealed class DependencyState(ScopeWitness scope, ShortCircuitSettings settings)
+{
+ public ScopeWitness Scope { get; } = scope;
+ public ShortCircuitSettings Settings { get; } = settings;
+}
+
+public abstract class DependencyComponent : WorkloadComponent
+{
+ public DependencyState State { get; }
+ public string Role { get; }
+
+ protected DependencyComponent(DependencyState state, string role) : base(state.Scope)
+ {
+ State = state;
+ Role = role;
+ state.Settings.Trace?.Constructed.Add(this);
+ }
+
+ protected void Enter(ShortCircuitMessage message, CancellationToken token) => Observe(message, Role, token);
+
+ public override void Dispose()
+ {
+ State.Settings.Trace?.Disposed.Add(this);
+ base.Dispose();
+ }
+}
diff --git a/benchmarks/RequestFlow.Benchmarks.Core/ShortCircuit/HandlerDependencies.cs b/benchmarks/RequestFlow.Benchmarks.Core/ShortCircuit/HandlerDependencies.cs
new file mode 100644
index 0000000..a7a02c2
--- /dev/null
+++ b/benchmarks/RequestFlow.Benchmarks.Core/ShortCircuit/HandlerDependencies.cs
@@ -0,0 +1,27 @@
+namespace RequestFlow.Benchmarks.Core;
+
+public sealed class CommandBuffer(DependencyState state) : DependencyComponent(state, "command-buffer")
+{
+ public byte[] Bytes { get; } = state.Settings.BufferBytes == 0
+ ? Array.Empty()
+ : new byte[state.Settings.BufferBytes];
+}
+
+public sealed class DataSession(DependencyState state, CommandBuffer buffer)
+ : DependencyComponent(state, "data-session")
+{
+ public int Capacity => buffer.Bytes.Length;
+}
+
+public sealed class Repository(DependencyState state, DataSession session) : DependencyComponent(state, "repository")
+{
+ public Task Read()
+ => session.Capacity >= 0
+ ? ShortCircuitWorkload.Handled
+ : throw new InvalidOperationException();
+
+ public ValueTask ReadValue()
+ => session.Capacity >= 0
+ ? new(ShortCircuitWorkload.HandledResult)
+ : throw new InvalidOperationException();
+}
diff --git a/benchmarks/RequestFlow.Benchmarks.Core/ShortCircuit/ShortCircuitMessage.cs b/benchmarks/RequestFlow.Benchmarks.Core/ShortCircuit/ShortCircuitMessage.cs
new file mode 100644
index 0000000..0c6e0f2
--- /dev/null
+++ b/benchmarks/RequestFlow.Benchmarks.Core/ShortCircuit/ShortCircuitMessage.cs
@@ -0,0 +1,13 @@
+namespace RequestFlow.Benchmarks.Core;
+
+public enum ShortCircuitOutcome
+{
+ AuthorizationReject,
+ CacheHit,
+ FullExecution
+}
+
+public class ShortCircuitMessage(ShortCircuitOutcome outcome) : WorkloadMessage
+{
+ public ShortCircuitOutcome Outcome { get; } = outcome;
+}
diff --git a/benchmarks/RequestFlow.Benchmarks.Core/ShortCircuit/ShortCircuitSettings.cs b/benchmarks/RequestFlow.Benchmarks.Core/ShortCircuit/ShortCircuitSettings.cs
new file mode 100644
index 0000000..5d83fbf
--- /dev/null
+++ b/benchmarks/RequestFlow.Benchmarks.Core/ShortCircuit/ShortCircuitSettings.cs
@@ -0,0 +1,9 @@
+namespace RequestFlow.Benchmarks.Core;
+
+public sealed record ShortCircuitSettings(int BufferBytes, ConstructionTrace? Trace = null);
+
+public sealed class ConstructionTrace
+{
+ public List Constructed { get; } = [];
+ public List Disposed { get; } = [];
+}
diff --git a/benchmarks/RequestFlow.Benchmarks.Core/ShortCircuit/ShortCircuitVerification.cs b/benchmarks/RequestFlow.Benchmarks.Core/ShortCircuit/ShortCircuitVerification.cs
new file mode 100644
index 0000000..bd48d71
--- /dev/null
+++ b/benchmarks/RequestFlow.Benchmarks.Core/ShortCircuit/ShortCircuitVerification.cs
@@ -0,0 +1,151 @@
+using Microsoft.Extensions.DependencyInjection;
+
+namespace RequestFlow.Benchmarks.Core;
+
+public enum DependencyResolution
+{
+ EnteredOnly,
+ AllStages,
+ EntireChain
+}
+
+public static class ShortCircuitVerification
+{
+ public static void Verify(
+ string library,
+ Func build,
+ Func createMessage,
+ Func> send,
+ DependencyResolution resolution,
+ bool childScopePerSend = false,
+ int authorizationDisposals = 1,
+ ServiceLifetime dispatcherLifetime = ServiceLifetime.Scoped)
+ where TDispatcher : notnull
+ where TMessage : ShortCircuitMessage
+ {
+ foreach (int bufferBytes in new[] { 0, 4096 })
+ foreach (ShortCircuitOutcome outcome in Enum.GetValues())
+ {
+ var construction = new ConstructionTrace();
+ using ServiceProvider provider = build(bufferBytes, construction);
+
+ var prior = new List();
+ using var cancellation = new CancellationTokenSource();
+
+ for (int scopeIndex = 0; scopeIndex < 2; scopeIndex++)
+ {
+ int scopeStart = construction.Constructed.Count;
+
+ using (IServiceScope scope = provider.CreateScope())
+ {
+ TDispatcher dispatcher = scope.ServiceProvider.GetRequiredService();
+
+ Require(
+ ReferenceEquals(dispatcher, scope.ServiceProvider.GetRequiredService())
+ == ScopeLifetimeVerification.SharesWithinScope(dispatcherLifetime),
+ "dispatcher lifetime");
+
+ for (int sendIndex = 0; sendIndex < 3; sendIndex++)
+ {
+ ShortCircuitOutcome selected = sendIndex == 2 ? ShortCircuitOutcome.FullExecution : outcome;
+ var trace = new ExecutionTrace();
+ TMessage message = createMessage(selected);
+ message.Trace = trace;
+
+ int start = construction.Constructed.Count;
+ ValueTask pending = send(dispatcher, message, cancellation.Token);
+
+ Require(pending.IsCompletedSuccessfully, "workload did not complete synchronously");
+
+ int result = pending.GetAwaiter().GetResult();
+ BenchmarkExpectation expectation = ShortCircuitWorkload.Expectation(selected);
+
+ Require(result == expectation.Result, "response");
+
+ Require(trace.Entries.Select(entry => entry.Name).SequenceEqual(expectation.Names), "execution order");
+ Require(trace.Entries.All(entry => entry.Token == cancellation.Token), "cancellation token");
+
+ var activated = construction.Constructed.Skip(childScopePerSend ? start : scopeStart).ToArray();
+ string[] expected = ExpectedRoles(selected, resolution);
+
+ Require(
+ activated.Select(component => component.Role).Order().SequenceEqual(expected.Order()),
+ "activation: " + string.Join(", ", activated.Select(component => component.Role)));
+
+ Require(
+ activated.Select(component => component.State).Distinct().Count() == 1,
+ "dependency scope identity");
+
+ Require(
+ trace.Entries.All(entry => activated.Contains(entry.Component)),
+ "executed untracked component");
+
+ Require(!activated.Any(prior.Contains), "component crossed a scope boundary");
+
+ Require(
+ !activated.Any(component =>
+ prior.Any(previous => ReferenceEquals(previous.State, component.State))),
+ "dependency state crossed a scope boundary");
+
+ if (!childScopePerSend && sendIndex == 1)
+ Require(construction.Constructed.Count == start, "scoped component was reconstructed");
+
+ Require(
+ activated.All(component => component.IsDisposed == childScopePerSend),
+ "premature or missing disposal");
+
+ if (childScopePerSend)
+ prior.AddRange(activated);
+
+ foreach (DependencyComponent component in activated)
+ {
+ if (component is ChangeBuffer change)
+ Require(change.Bytes.Length == bufferBytes, "transaction buffer size");
+
+ if (component is CommandBuffer command)
+ Require(command.Bytes.Length == bufferBytes, "handler buffer size");
+ }
+ }
+ }
+
+ DependencyComponent[] completed = construction.Constructed.Skip(scopeStart).ToArray();
+
+ foreach (DependencyComponent component in completed)
+ {
+ Require(component.IsDisposed && component.Scope.IsDisposed, "scope disposal");
+
+ int expectedCount = component.Role == "authorization" ? authorizationDisposals : 1;
+
+ Require(
+ construction.Disposed.Count(item => ReferenceEquals(item, component)) == expectedCount,
+ "disposal count: " + component.Role);
+ }
+
+ if (!childScopePerSend)
+ prior.AddRange(completed);
+ }
+
+ void Require(bool condition, string detail)
+ {
+ if (!condition)
+ throw new InvalidOperationException($"{library} short circuit ({outcome}, buffer={bufferBytes}): {detail}.");
+ }
+ }
+ }
+
+ private static string[] ExpectedRoles(ShortCircuitOutcome outcome, DependencyResolution resolution)
+ {
+ string[] authorization = ["access-policy", "authorization"];
+ string[] cache = [.. authorization, "cache-policy", "cache"];
+ string[] stages = [.. cache, "change-buffer", "unit-of-work", "transaction"];
+ string[] full = [.. stages, "command-buffer", "data-session", "repository", TraceNames.Handler];
+
+ if (outcome == ShortCircuitOutcome.FullExecution || resolution == DependencyResolution.EntireChain)
+ return full;
+
+ if (resolution == DependencyResolution.AllStages)
+ return stages;
+
+ return outcome == ShortCircuitOutcome.AuthorizationReject ? authorization : cache;
+ }
+}
diff --git a/benchmarks/RequestFlow.Benchmarks.Core/ShortCircuit/ShortCircuitWorkload.cs b/benchmarks/RequestFlow.Benchmarks.Core/ShortCircuit/ShortCircuitWorkload.cs
new file mode 100644
index 0000000..9976508
--- /dev/null
+++ b/benchmarks/RequestFlow.Benchmarks.Core/ShortCircuit/ShortCircuitWorkload.cs
@@ -0,0 +1,45 @@
+using Microsoft.Extensions.DependencyInjection;
+
+namespace RequestFlow.Benchmarks.Core;
+
+public static class ShortCircuitWorkload
+{
+ public const int RejectedResult = 71;
+ public const int CachedResult = 73;
+ public const int HandledResult = 79;
+
+ public static readonly Task Rejected = Task.FromResult(RejectedResult);
+ public static readonly Task Cached = Task.FromResult(CachedResult);
+ public static readonly Task Handled = Task.FromResult(HandledResult);
+
+ public static BenchmarkExpectation Expectation(IReadOnlyDictionary parameters)
+ => Expectation((ShortCircuitOutcome)parameters["Outcome"]!);
+
+ public static BenchmarkExpectation Expectation(ShortCircuitOutcome outcome) => outcome switch
+ {
+ ShortCircuitOutcome.AuthorizationReject => new(["authorization"], RejectedResult),
+ ShortCircuitOutcome.CacheHit => new(["authorization", "cache"], CachedResult),
+ ShortCircuitOutcome.FullExecution => new(["authorization", "cache", "transaction", TraceNames.Handler], HandledResult),
+ _ => throw new ArgumentOutOfRangeException(nameof(outcome)),
+ };
+
+ public static void AddDependencies(
+ IServiceCollection services,
+ int bufferBytes = 0,
+ ConstructionTrace? trace = null)
+ {
+ services.AddScoped();
+ services.AddSingleton(new ShortCircuitSettings(bufferBytes, trace));
+ services.AddScoped();
+
+ services.AddScoped();
+ services.AddScoped();
+
+ services.AddScoped();
+ services.AddScoped();
+
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ }
+}
diff --git a/benchmarks/RequestFlow.Benchmarks.Core/ShortCircuit/StageDependencies.cs b/benchmarks/RequestFlow.Benchmarks.Core/ShortCircuit/StageDependencies.cs
new file mode 100644
index 0000000..82d42c7
--- /dev/null
+++ b/benchmarks/RequestFlow.Benchmarks.Core/ShortCircuit/StageDependencies.cs
@@ -0,0 +1,23 @@
+namespace RequestFlow.Benchmarks.Core;
+
+public sealed class AccessPolicy(DependencyState state) : DependencyComponent(state, "access-policy")
+{
+ public bool Allows(ShortCircuitMessage message) => message.Outcome != ShortCircuitOutcome.AuthorizationReject;
+}
+
+public sealed class CachePolicy(DependencyState state) : DependencyComponent(state, "cache-policy")
+{
+ public bool Contains(ShortCircuitMessage message) => message.Outcome == ShortCircuitOutcome.CacheHit;
+}
+
+public sealed class ChangeBuffer(DependencyState state) : DependencyComponent(state, "change-buffer")
+{
+ public byte[] Bytes { get; } = state.Settings.BufferBytes == 0
+ ? Array.Empty()
+ : new byte[state.Settings.BufferBytes];
+}
+
+public sealed class UnitOfWork(DependencyState state, ChangeBuffer buffer) : DependencyComponent(state, "unit-of-work")
+{
+ public int Capacity => buffer.Bytes.Length;
+}
diff --git a/benchmarks/RequestFlow.Benchmarks.Core/Verification/BenchmarkExpectation.cs b/benchmarks/RequestFlow.Benchmarks.Core/Verification/BenchmarkExpectation.cs
new file mode 100644
index 0000000..ff226f1
--- /dev/null
+++ b/benchmarks/RequestFlow.Benchmarks.Core/Verification/BenchmarkExpectation.cs
@@ -0,0 +1,60 @@
+namespace RequestFlow.Benchmarks.Core;
+
+///
+/// The expected workload for one benchmark parameter set.
+///
+/// The benchmark method whose library resolves handlers in a child scope per send.
+/// How many child scopes one call of opens.
+/// Benchmark methods that return no response, so does not apply to them.
+public sealed record BenchmarkExpectation(
+ string[] Names,
+ int? Result = null,
+ bool Ordered = true,
+ int ParallelHandlers = 0,
+ IReadOnlyDictionary? HandlerTypes = null,
+ string? ChildScopeMethod = null,
+ int ChildScopes = 1,
+ IReadOnlyCollection? VoidMethods = null)
+{
+ ///
+ /// A request that returns the shared workload result.
+ ///
+ public static BenchmarkExpectation Request(int stages = 0, string prefix = TraceNames.StagePrefix)
+ => new(BenchmarkVerification.RequestNames(stages, prefix), BenchmarkWorkload.Expected);
+
+ ///
+ /// A request without a response value.
+ ///
+ public static BenchmarkExpectation VoidRequest(int stages = 0, string prefix = TraceNames.StagePrefix)
+ => new(BenchmarkVerification.RequestNames(stages, prefix));
+
+ ///
+ /// A fully consumed stream with its checksum.
+ ///
+ public static BenchmarkExpectation Stream(int stages, int count)
+ => new(BenchmarkVerification.StreamNames(stages, count), count * (count - 1) / 2);
+
+ ///
+ /// An event with the declared handler ordering and fan-out gate.
+ ///
+ public static BenchmarkExpectation Event(int handlers, bool ordered = true, int parallelHandlers = 0)
+ => new(BenchmarkVerification.EventNames(handlers), Ordered: ordered, ParallelHandlers: parallelHandlers);
+
+ ///
+ /// Checks the trace, result and declared handler types.
+ ///
+ public void Verify(BenchmarkObservation observation)
+ {
+ int? result = VoidMethods?.Contains(observation.Method) == true ? null : Result;
+
+ BenchmarkVerification.VerifyWorkload(observation, Names, result, ordered: Ordered);
+
+ if (HandlerTypes is null)
+ return;
+
+ if (!HandlerTypes.TryGetValue(observation.Method, out Type[]? expectedTypes))
+ throw new InvalidOperationException($"Benchmark '{observation.BenchmarkType.Name}.{observation.Method}' has no handler type expectations.");
+
+ BenchmarkVerification.VerifyHandlerTypes(observation, expectedTypes);
+ }
+}
diff --git a/benchmarks/RequestFlow.Benchmarks.Core/Verification/BenchmarkObservation.cs b/benchmarks/RequestFlow.Benchmarks.Core/Verification/BenchmarkObservation.cs
new file mode 100644
index 0000000..836a7e5
--- /dev/null
+++ b/benchmarks/RequestFlow.Benchmarks.Core/Verification/BenchmarkObservation.cs
@@ -0,0 +1,8 @@
+namespace RequestFlow.Benchmarks.Core;
+
+public sealed record BenchmarkObservation(
+ Type BenchmarkType,
+ string Method,
+ IReadOnlyDictionary Parameters,
+ object? Result,
+ IReadOnlyList? Trace = null);
diff --git a/benchmarks/RequestFlow.Benchmarks.Core/Verification/BenchmarkScopeAttribute.cs b/benchmarks/RequestFlow.Benchmarks.Core/Verification/BenchmarkScopeAttribute.cs
new file mode 100644
index 0000000..662a0d1
--- /dev/null
+++ b/benchmarks/RequestFlow.Benchmarks.Core/Verification/BenchmarkScopeAttribute.cs
@@ -0,0 +1,10 @@
+namespace RequestFlow.Benchmarks.Core;
+
+///
+/// The scope lifetime that the probe checks for every method of a benchmark class.
+///
+[AttributeUsage(AttributeTargets.Class)]
+public sealed class BenchmarkScopeAttribute(ScopeLifetime lifetime) : Attribute
+{
+ public ScopeLifetime Lifetime { get; } = lifetime;
+}
diff --git a/benchmarks/RequestFlow.Benchmarks.Core/Verification/BenchmarkVerification.cs b/benchmarks/RequestFlow.Benchmarks.Core/Verification/BenchmarkVerification.cs
new file mode 100644
index 0000000..bd7ba07
--- /dev/null
+++ b/benchmarks/RequestFlow.Benchmarks.Core/Verification/BenchmarkVerification.cs
@@ -0,0 +1,368 @@
+using System.Reflection;
+using BenchmarkDotNet.Attributes;
+using RequestFlow.Benchmarks.Harness;
+
+namespace RequestFlow.Benchmarks.Core;
+
+public static class BenchmarkVerification
+{
+ ///
+ /// Checks the result and trace of a benchmark call, which passes .
+ ///
+ public static void VerifyWorkload(
+ BenchmarkObservation observation, string[] expectedNames, int? expectedResult = null, bool ordered = true)
+ {
+ string label = Label(observation);
+
+ if (expectedResult is not null && !Equals(observation.Result, expectedResult.Value))
+ throw new InvalidOperationException($"{label} returned {observation.Result ?? "no result"}, expected {expectedResult}.");
+
+ TraceAssertions.VerifyRecords(label, observation.Trace ?? [], expectedNames, CancellationToken.None, ordered);
+ }
+
+ public static string[] RequestNames(int stages = 0, string prefix = TraceNames.StagePrefix)
+ => [.. Enumerable.Range(1, stages).Select(index => prefix + index), TraceNames.Handler];
+
+ ///
+ /// Returns the stream trace names. An early stop reads one item and records no completion.
+ ///
+ public static string[] StreamNames(int stages, int count, bool early = false)
+ => [.. RequestNames(stages), .. Enumerable.Range(0, early ? 1 : count).Select(index => TraceNames.ItemPrefix + index),
+ .. early ? Array.Empty() : new[] { TraceNames.StreamComplete }, TraceNames.StreamDispose];
+
+ public static string[] EventNames(int handlers)
+ => Enumerable.Range(1, handlers)
+ .SelectMany(index => new[]
+ {
+ TraceNames.EventHandlerPrefix + index,
+ TraceNames.EventHandlerPrefix + index + TraceNames.CompletionSuffix,
+ })
+ .ToArray();
+
+ public static void VerifyHandlerTypes(BenchmarkObservation observation, Type[] expectedTypes)
+ {
+ Type[] actual = (observation.Trace ?? [])
+ .Where(entry => entry.Name == TraceNames.Handler || TraceNames.IsEventHandlerStart(entry.Name))
+ .Select(entry => entry.Component.GetType()).ToArray();
+
+ if (!actual.SequenceEqual(expectedTypes))
+ {
+ string expectedTypeNames = string.Join(", ", expectedTypes.Select(type => type.ToString()));
+ string actualTypeNames = string.Join(", ", actual.Select(type => type.ToString()));
+
+ throw new InvalidOperationException(
+ $"Benchmark '{observation.BenchmarkType.Name}.{observation.Method}' executed the wrong handler types. " +
+ $"Expected: {expectedTypeNames}. Actual: {actualTypeNames}.");
+ }
+ }
+
+ ///
+ /// Returns the values of a on a benchmark property or field.
+ ///
+ public static T[] Params(Type benchmarkType, string member)
+ {
+ MemberInfo declaration = benchmarkType.GetMember(member, BindingFlags.Public | BindingFlags.Instance).SingleOrDefault()
+ ?? throw new InvalidOperationException($"'{benchmarkType.Name}' has no member '{member}'.");
+ ParamsAttribute attribute = declaration.GetCustomAttribute()
+ ?? throw new InvalidOperationException($"'{benchmarkType.Name}.{member}' has no [Params] attribute.");
+
+ return attribute.Values.Cast().ToArray();
+ }
+
+ ///
+ /// Runs every benchmark method of every parameter set twice and checks it against its class expectation.
+ ///
+ ///
+ /// Every class declares its scope lifetime with .
+ /// A class must use a new scope per call and dispose it before the call completes.
+ /// A class must retain one scope across calls.
+ /// A class is exempt from scope checks.
+ /// A Completion parameter fixes whether the returned awaitable is complete when the call returns.
+ ///
+ public static BenchmarkProbeResult Run(
+ Assembly assembly,
+ IReadOnlyDictionary, BenchmarkExpectation>> expectations)
+ => Run(
+ assembly.GetTypes().Where(type => type.IsPublic && !type.IsAbstract &&
+ FindMethodsWithAttribute(type, typeof(BenchmarkAttribute)).Length > 0).ToArray(),
+ expectations);
+
+ internal static BenchmarkProbeResult Run(
+ Type[] benchmarkTypes,
+ IReadOnlyDictionary, BenchmarkExpectation>> expectations)
+ {
+ foreach (Type type in benchmarkTypes)
+ {
+ if (!expectations.ContainsKey(type))
+ throw new InvalidOperationException($"Benchmark '{type.Name}' has no workload expectations.");
+
+ // Throws when the class declares no scope lifetime.
+ ScopeVerification.For(type);
+ }
+
+ int parameterSets = 0;
+ int cases = 0;
+
+ foreach (Type type in benchmarkTypes)
+ {
+ MethodInfo[] methods = FindMethodsWithAttribute(type, typeof(BenchmarkAttribute));
+
+ VerifyBaselines(type, methods);
+
+ if (methods.Select(method => method.GetCustomAttribute()!.OperationsPerInvoke).Distinct().Count() != 1)
+ throw new InvalidOperationException($"'{type.Name}' must use one OperationsPerInvoke for every method.");
+
+ foreach (Dictionary parameters in EnumerateParameterSets(type))
+ {
+ VerifyParameterSet(type, methods, parameters, expectations[type](parameters));
+
+ parameterSets++;
+ cases += methods.Length;
+ }
+ }
+
+ if (cases == 0)
+ throw new InvalidOperationException("No benchmark methods were verified.");
+
+ return new BenchmarkProbeResult(benchmarkTypes.Length, parameterSets, cases);
+ }
+
+ ///
+ /// Requires one baseline per group. Methods without a category form one group.
+ ///
+ internal static void VerifyBaselines(Type type, MethodInfo[] methods)
+ {
+ foreach (IGrouping group in methods.GroupBy(CategoryKey, StringComparer.Ordinal))
+ {
+ if (group.Count(method => method.GetCustomAttribute()!.Baseline) != 1)
+ {
+ string category = group.Key.Length == 0 ? "its uncategorized methods" : $"category '{group.Key}'";
+
+ throw new InvalidOperationException($"'{type.Name}' must declare one baseline for {category}.");
+ }
+ }
+ }
+
+ private static string CategoryKey(MethodInfo method)
+ => string.Join(",", method.GetCustomAttributes(true)
+ .SelectMany(attribute => attribute.Categories).Distinct().Order(StringComparer.Ordinal));
+
+ // BenchmarkDotNet gives every benchmark method its own instance and setup, so the probe does too.
+ private static void VerifyParameterSet(
+ Type type,
+ MethodInfo[] methods,
+ Dictionary parameters,
+ BenchmarkExpectation expectation)
+ {
+ foreach (MethodInfo method in methods)
+ {
+ object instance = CreateInstance(type, parameters);
+
+ try
+ {
+ InvokeLifecycleMethod(instance, method, typeof(GlobalSetupAttribute));
+
+ BenchmarkObservation first = ObserveMethod(instance, method, parameters, expectation);
+ BenchmarkObservation second = ObserveMethod(instance, method, parameters, expectation);
+
+ ScopeVerification.VerifyAcrossCalls(first, second, expectation);
+
+ expectation.Verify(first);
+ expectation.Verify(second);
+ }
+ catch (Exception exception)
+ {
+ string parameterDescription = string.Join(", ", parameters.Select(pair => pair.Key + "=" + pair.Value));
+ Exception cause = exception is TargetInvocationException invocation
+ ? invocation.InnerException ?? exception
+ : exception;
+
+ throw new InvalidOperationException(
+ $"Benchmark correctness verification failed for '{type.Name}.{method.Name}' ({parameterDescription}).", cause);
+ }
+ finally
+ {
+ InvokeLifecycleMethod(instance, method, typeof(GlobalCleanupAttribute));
+ }
+ }
+ }
+
+ private static object CreateInstance(Type type, IReadOnlyDictionary parameters)
+ {
+ object instance = Activator.CreateInstance(type)
+ ?? throw new InvalidOperationException($"Cannot construct '{type.FullName}'.");
+
+ foreach ((string name, object? value) in parameters)
+ {
+ if (type.GetProperty(name) is PropertyInfo property)
+ property.SetValue(instance, value);
+ else
+ type.GetField(name)!.SetValue(instance, value);
+ }
+
+ return instance;
+ }
+
+ // Matches BenchmarkDotNet: a lifecycle method that targets the benchmark wins over an untargeted one.
+ private static void InvokeLifecycleMethod(object instance, MethodInfo benchmark, Type attribute)
+ {
+ MethodInfo? lifecycle = FindMethodsWithAttribute(instance.GetType(), attribute)
+ .Select(method => (Method: method, Attribute: (TargetedAttribute)method.GetCustomAttribute(attribute, true)!))
+ .OrderByDescending(candidate => candidate.Attribute.Targets.Length)
+ .FirstOrDefault(candidate => candidate.Attribute.Match(benchmark))
+ .Method;
+
+ if (lifecycle is not null)
+ WaitForResult(lifecycle.Invoke(instance, null));
+ }
+
+ private static BenchmarkObservation ObserveMethod(
+ object instance, MethodInfo method, IReadOnlyDictionary parameters, BenchmarkExpectation expectation)
+ {
+ Type type = instance.GetType();
+ var trace = new ExecutionTrace(expectation.ParallelHandlers);
+ // A field holds one message or an array of them.
+ var messages = InstanceFields(type)
+ .Select(field => field.GetValue(instance))
+ .SelectMany(value => value as object?[] ?? [value])
+ .OfType()
+ .Distinct()
+ .Select(message => (Message: message, OriginalTrace: message.Trace))
+ .ToArray();
+
+ try
+ {
+ foreach (var message in messages)
+ message.Message.Trace = trace;
+
+ object? operation = null;
+ object? result;
+ bool? completedOnReturn = null;
+
+ try
+ {
+ using (var continuations = new DeferredContinuations())
+ {
+ operation = method.Invoke(instance, null);
+ completedOnReturn = IsCompleted(operation);
+
+ if (continuations.DeadlineExpired)
+ throw new InvalidOperationException($"'{type.Name}.{method.Name}' blocked on its own continuation.");
+ }
+
+ trace.VerifyFanOut();
+ }
+ finally
+ {
+ trace.ReleaseHandlers();
+ result = WaitForResult(operation);
+ }
+
+ trace.VerifyCompletion();
+
+ var observation = new BenchmarkObservation(type, method.Name, parameters, result, trace.Entries);
+
+ VerifyCompletionShape(observation, completedOnReturn);
+ ScopeVerification.VerifyAfterCall(observation, expectation);
+
+ return observation;
+ }
+ finally
+ {
+ foreach (var message in messages)
+ message.Message.Trace = message.OriginalTrace;
+ }
+ }
+
+ private static void VerifyCompletionShape(BenchmarkObservation observation, bool? completedOnReturn)
+ {
+ bool? synchronous = observation.Parameters.TryGetValue("Completion", out object? completion) && completion is CompletionKind kind
+ ? kind != CompletionKind.Suspending
+ : null;
+
+ if (synchronous is null)
+ return;
+
+ if (completedOnReturn is null)
+ throw new InvalidOperationException($"{Label(observation)} returns no awaitable, so its completion shape cannot be checked.");
+
+ if (completedOnReturn != synchronous)
+ {
+ string expected = synchronous.Value ? "complete synchronously" : "suspend";
+
+ throw new InvalidOperationException($"{Label(observation)} did not {expected}.");
+ }
+ }
+
+ private static bool? IsCompleted(object? operation)
+ => operation?.GetType().GetProperty(nameof(Task.IsCompleted))?.GetValue(operation) as bool?;
+
+ internal static string Label(BenchmarkObservation observation)
+ {
+ string parameterDescription = string.Join(
+ ", ",
+ observation.Parameters.Select(pair => pair.Key + "=" + pair.Value));
+
+ return $"Benchmark '{observation.BenchmarkType.Name}.{observation.Method}' ({parameterDescription})";
+ }
+
+ // Abstract benchmark bases declare private fields that GetFields does not return for the derived type.
+ private static IEnumerable InstanceFields(Type type)
+ {
+ for (Type? current = type; current is not null; current = current.BaseType)
+ {
+ foreach (FieldInfo field in current.GetFields(
+ BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly))
+ yield return field;
+ }
+ }
+
+ private static MethodInfo[] FindMethodsWithAttribute(Type type, Type attribute)
+ => type.GetMethods(BindingFlags.Public | BindingFlags.Instance)
+ .Where(method => method.IsDefined(attribute, true)).ToArray();
+
+ private static IEnumerable> EnumerateParameterSets(Type type)
+ {
+ MemberInfo[] members = type.GetMembers(BindingFlags.Public | BindingFlags.Instance)
+ .Where(member => member.GetCustomAttribute() is not null).ToArray();
+ IEnumerable> sets = [new Dictionary()];
+
+ foreach (MemberInfo member in members)
+ {
+ object?[] values = member.GetCustomAttribute()!.Values;
+ sets = sets.SelectMany(previous => values.Select(value =>
+ {
+ var next = new Dictionary(previous) { [member.Name] = value };
+
+ return next;
+ })).ToArray();
+ }
+
+ return sets;
+ }
+
+ private static object? WaitForResult(object? result)
+ {
+ if (result is ValueTask valueTask)
+ {
+ valueTask.AsTask().GetAwaiter().GetResult();
+
+ return null;
+ }
+
+ if (result is not null && result.GetType().IsGenericType &&
+ result.GetType().GetGenericTypeDefinition() == typeof(ValueTask<>))
+ {
+ result = result.GetType().GetMethod("AsTask")!.Invoke(result, null);
+ }
+
+ if (result is Task task)
+ {
+ task.GetAwaiter().GetResult();
+
+ return task.GetType().GetProperty("Result")?.GetValue(task);
+ }
+
+ return result;
+ }
+}
diff --git a/benchmarks/RequestFlow.Benchmarks.Core/Verification/ComparedLibrary.cs b/benchmarks/RequestFlow.Benchmarks.Core/Verification/ComparedLibrary.cs
new file mode 100644
index 0000000..7ef7d9e
--- /dev/null
+++ b/benchmarks/RequestFlow.Benchmarks.Core/Verification/ComparedLibrary.cs
@@ -0,0 +1,14 @@
+using Microsoft.Extensions.DependencyInjection;
+
+namespace RequestFlow.Benchmarks.Core;
+
+///
+/// One library in a paired scope check: its label, provider build, and the registrations its report prints.
+///
+/// Service types the report prints besides the dispatcher.
+/// Open generic interfaces whose implementations the report prints.
+public sealed record ComparedLibrary(
+ string Name,
+ Func Build,
+ IReadOnlyCollection? ReportedServices = null,
+ IReadOnlyCollection? ReportedContracts = null);
diff --git a/benchmarks/RequestFlow.Benchmarks.Core/Verification/DeferredContinuations.cs b/benchmarks/RequestFlow.Benchmarks.Core/Verification/DeferredContinuations.cs
new file mode 100644
index 0000000..9ce287e
--- /dev/null
+++ b/benchmarks/RequestFlow.Benchmarks.Core/Verification/DeferredContinuations.cs
@@ -0,0 +1,70 @@
+namespace RequestFlow.Benchmarks.Core;
+
+// Holds continuations posted during a probe call, so a suspending operation cannot finish before the probe reads its state.
+// A call that blocks on its own deferred continuation is released after the deadline and fails the probe.
+internal sealed class DeferredContinuations : SynchronizationContext, IDisposable
+{
+ private static readonly TimeSpan Deadline = TimeSpan.FromSeconds(1);
+
+ private readonly SynchronizationContext? _previous = Current;
+ private readonly Queue<(SendOrPostCallback Callback, object? State)> _pending = new();
+ private readonly Timer _deadline;
+ private bool _released;
+
+ public bool DeadlineExpired { get; private set; }
+
+ public DeferredContinuations()
+ {
+ _deadline = new Timer(_ => Expire(), null, Timeout.Infinite, Timeout.Infinite);
+ SetSynchronizationContext(this);
+ }
+
+ public override void Post(SendOrPostCallback d, object? state)
+ {
+ lock (_pending)
+ {
+ if (!_released)
+ {
+ _pending.Enqueue((d, state));
+ _deadline.Change(Deadline, Timeout.InfiniteTimeSpan);
+
+ return;
+ }
+ }
+
+ Forward((d, state));
+ }
+
+ public override SynchronizationContext CreateCopy() => this;
+
+ public void Dispose()
+ {
+ SetSynchronizationContext(_previous);
+ Release();
+ _deadline.Dispose();
+ }
+
+ private void Expire()
+ {
+ DeadlineExpired = true;
+ Release();
+ }
+
+ private void Release()
+ {
+ (SendOrPostCallback Callback, object? State)[] pending;
+
+ lock (_pending)
+ {
+ _released = true;
+ pending = _pending.ToArray();
+ _pending.Clear();
+ }
+
+ foreach ((SendOrPostCallback Callback, object? State) item in pending)
+ Forward(item);
+ }
+
+ private static void Forward((SendOrPostCallback Callback, object? State) item)
+ => ThreadPool.QueueUserWorkItem(static item => item.Callback(item.State), item, preferLocal: false);
+}
diff --git a/benchmarks/RequestFlow.Benchmarks.Core/Verification/ExecutionRecord.cs b/benchmarks/RequestFlow.Benchmarks.Core/Verification/ExecutionRecord.cs
new file mode 100644
index 0000000..2e01a98
--- /dev/null
+++ b/benchmarks/RequestFlow.Benchmarks.Core/Verification/ExecutionRecord.cs
@@ -0,0 +1,4 @@
+namespace RequestFlow.Benchmarks.Core;
+
+public sealed record ExecutionRecord(
+ string Name, WorkloadComponent Component, ScopeWitness Scope, CancellationToken Token);
diff --git a/benchmarks/RequestFlow.Benchmarks.Core/Verification/ExecutionTrace.cs b/benchmarks/RequestFlow.Benchmarks.Core/Verification/ExecutionTrace.cs
new file mode 100644
index 0000000..c0042d1
--- /dev/null
+++ b/benchmarks/RequestFlow.Benchmarks.Core/Verification/ExecutionTrace.cs
@@ -0,0 +1,96 @@
+using System.Collections.Concurrent;
+
+namespace RequestFlow.Benchmarks.Core;
+
+public sealed class ExecutionTrace(int parallelHandlers = 0)
+{
+ private readonly ConcurrentQueue _entries = new();
+ private readonly ConcurrentQueue _operations = new();
+ private int _completedHolds;
+ private readonly TaskCompletionSource? _handlerRelease = parallelHandlers > 0
+ ? new(TaskCreationOptions.RunContinuationsAsynchronously) : null;
+
+ public IReadOnlyList Entries => _entries.ToArray();
+
+ public void Record(ExecutionRecord entry) => _entries.Enqueue(entry);
+
+ public TTask Track(TTask operation) where TTask : Task
+ {
+ _operations.Enqueue(operation);
+
+ return operation;
+ }
+
+ public ValueTask Track(ValueTask operation) => new(Track(operation.AsTask()));
+
+ public ValueTask Track(ValueTask operation) => new(Track(operation.AsTask()));
+
+ // Handlers are held only on suspending rows, so a held handler that already completed ignored its completion shape.
+ internal Task HoldHandlers(Task operation)
+ => _handlerRelease is null ? operation : WaitForRelease(Held(operation));
+
+ internal ValueTask HoldHandlers(ValueTask operation)
+ => _handlerRelease is null ? operation : new ValueTask(WaitForRelease(Held(operation.AsTask())));
+
+ internal Task HoldHandlers(Task operation)
+ => _handlerRelease is null ? operation : WaitForRelease(Held(operation));
+
+ internal ValueTask HoldHandlers(ValueTask operation)
+ => _handlerRelease is null ? operation : new ValueTask(WaitForRelease(Held(operation.AsTask())));
+
+ private TTask Held(TTask operation) where TTask : Task
+ {
+ if (operation.IsCompleted)
+ Interlocked.Increment(ref _completedHolds);
+
+ return operation;
+ }
+
+ internal void VerifyFanOut()
+ {
+ if (_handlerRelease is null)
+ return;
+
+ ExecutionRecord[] entries = _entries.ToArray();
+ int started = entries.Count(entry => TraceNames.IsEventHandlerStart(entry.Name));
+ bool hasCompletedOperations = entries.Any(
+ entry => entry.Name.EndsWith(TraceNames.CompletionSuffix, StringComparison.Ordinal));
+
+ if (started != parallelHandlers || hasCompletedOperations)
+ throw new InvalidOperationException($"Expected all {parallelHandlers} event handlers to start before completion was released; got {started} starts.");
+
+ if (_completedHolds != 0)
+ throw new InvalidOperationException($"{_completedHolds} held event handlers completed synchronously on a suspending row.");
+ }
+
+ internal void ReleaseHandlers() => _handlerRelease?.TrySetResult();
+
+ private async Task WaitForRelease(Task operation)
+ {
+ await _handlerRelease!.Task.ConfigureAwait(false);
+ await operation.ConfigureAwait(false);
+ }
+
+ private async Task WaitForRelease(Task operation)
+ {
+ await _handlerRelease!.Task.ConfigureAwait(false);
+
+ return await operation.ConfigureAwait(false);
+ }
+
+ internal void VerifyCompletion()
+ {
+ if (_operations.Any(operation => !operation.IsCompletedSuccessfully))
+ throw new InvalidOperationException("The benchmark returned before its workload completed successfully.");
+
+ int handlerStarts = _entries.Count(IsHandlerStart);
+ int completedStreams = _entries.Count(entry => entry.Name == TraceNames.StreamComplete);
+ int expectedOperations = handlerStarts - completedStreams;
+
+ if (_operations.Count != expectedOperations)
+ throw new InvalidOperationException("The benchmark did not complete one workload per handler.");
+ }
+
+ private static bool IsHandlerStart(ExecutionRecord entry)
+ => entry.Name == TraceNames.Handler || TraceNames.IsEventHandlerStart(entry.Name);
+}
diff --git a/benchmarks/RequestFlow.Benchmarks.Core/Verification/RegistrationReport.cs b/benchmarks/RequestFlow.Benchmarks.Core/Verification/RegistrationReport.cs
new file mode 100644
index 0000000..c1e07ca
--- /dev/null
+++ b/benchmarks/RequestFlow.Benchmarks.Core/Verification/RegistrationReport.cs
@@ -0,0 +1,65 @@
+using Microsoft.Extensions.DependencyInjection;
+
+namespace RequestFlow.Benchmarks.Core;
+
+///
+/// Lists and filters the DI descriptors the benchmark fixtures register.
+///
+public static class RegistrationReport
+{
+ ///
+ /// Prints workload components, the dispatcher, , and implementations of .
+ ///
+ /// Service types to print; an open generic type matches every closed registration of it.
+ /// Open generic interfaces whose implementations to print, whatever service type registers them.
+ public static void ReportRegistrations(
+ string label,
+ IServiceCollection registrations,
+ Type dispatcherType,
+ IReadOnlyCollection? reportedServices = null,
+ IReadOnlyCollection? reportedContracts = null)
+ {
+ Console.WriteLine(label);
+
+ Type[] services = [typeof(ScopeWitness), dispatcherType, .. reportedServices ?? []];
+ IReadOnlyCollection contracts = reportedContracts ?? [];
+
+ foreach (ServiceDescriptor descriptor in registrations.Where(descriptor =>
+ IsComponent(descriptor) || Implements(descriptor, contracts) ||
+ services.Any(service => Matches(descriptor.ServiceType, service))))
+ {
+ string implementation = GetImplementationType(descriptor)?.ToString() ??
+ (descriptor.IsKeyedService ? descriptor.KeyedImplementationInstance : descriptor.ImplementationInstance)?.GetType().ToString() ??
+ "factory";
+ object? serviceKey = descriptor.IsKeyedService ? descriptor.ServiceKey : "none";
+
+ Console.WriteLine($" {descriptor.ServiceType}: {descriptor.Lifetime}; implementation={implementation}; key={serviceKey}");
+ }
+ }
+
+ ///
+ /// Returns registrations for workload components.
+ ///
+ public static IEnumerable GetComponentDescriptors(IServiceCollection services)
+ => services.Where(IsComponent);
+
+ internal static Type? GetImplementationType(ServiceDescriptor descriptor)
+ => descriptor.IsKeyedService ? descriptor.KeyedImplementationType : descriptor.ImplementationType;
+
+ private static bool IsComponent(ServiceDescriptor descriptor)
+ => GetImplementationType(descriptor) is Type implementation &&
+ typeof(WorkloadComponent).IsAssignableFrom(implementation);
+
+ private static bool Implements(ServiceDescriptor descriptor, IReadOnlyCollection contracts)
+ {
+ Type type = GetImplementationType(descriptor) ?? descriptor.ServiceType;
+
+ return type.GetInterfaces().Any(contract =>
+ contract.IsGenericType && contracts.Contains(contract.GetGenericTypeDefinition()));
+ }
+
+ private static bool Matches(Type serviceType, Type reported)
+ => serviceType == reported ||
+ (reported.IsGenericTypeDefinition && serviceType.IsGenericType &&
+ serviceType.GetGenericTypeDefinition() == reported);
+}
diff --git a/benchmarks/RequestFlow.Benchmarks.Core/Verification/ScopeLifetime.cs b/benchmarks/RequestFlow.Benchmarks.Core/Verification/ScopeLifetime.cs
new file mode 100644
index 0000000..ea366ca
--- /dev/null
+++ b/benchmarks/RequestFlow.Benchmarks.Core/Verification/ScopeLifetime.cs
@@ -0,0 +1,19 @@
+namespace RequestFlow.Benchmarks.Core;
+
+public enum ScopeLifetime
+{
+ ///
+ /// Every call runs in one scope created before timing, which stays undisposed.
+ ///
+ RetainedScope,
+
+ ///
+ /// Every call creates its own scope and disposes it before completing.
+ ///
+ ScopePerOperation,
+
+ ///
+ /// Calls invoke a handler instance directly, so no scope is checked.
+ ///
+ DirectHandler,
+}
diff --git a/benchmarks/RequestFlow.Benchmarks.Core/Verification/ScopeLifetimeVerification.cs b/benchmarks/RequestFlow.Benchmarks.Core/Verification/ScopeLifetimeVerification.cs
new file mode 100644
index 0000000..8f2d0ad
--- /dev/null
+++ b/benchmarks/RequestFlow.Benchmarks.Core/Verification/ScopeLifetimeVerification.cs
@@ -0,0 +1,231 @@
+using Microsoft.Extensions.DependencyInjection;
+using static RequestFlow.Benchmarks.Core.RegistrationReport;
+using static RequestFlow.Benchmarks.Core.TraceAssertions;
+
+namespace RequestFlow.Benchmarks.Core;
+
+///
+/// Checks scoped component identities, execution traces and disposal.
+///
+public static class ScopeLifetimeVerification
+{
+ ///
+ /// Checks registrations and execution across two scopes.
+ ///
+ public static void VerifyScopes(
+ string label,
+ ServiceCollection registrations,
+ Func build,
+ Type dispatcherType,
+ Func execute,
+ string[] expectedNames,
+ bool report,
+ bool ordered = true,
+ IReadOnlyCollection? reportedServices = null,
+ IReadOnlyCollection? reportedContracts = null,
+ ServiceLifetime dispatcherLifetime = ServiceLifetime.Scoped)
+ {
+ ServiceDescriptor[] components = GetComponentDescriptors(registrations).ToArray();
+
+ VerifyComponentRegistrations(label, components);
+
+ Require(
+ registrations.LastOrDefault(descriptor => descriptor.ServiceType == dispatcherType)?.Lifetime == dispatcherLifetime,
+ label + " does not register its dispatcher as " + dispatcherLifetime + ".");
+
+ if (report)
+ ReportRegistrations(label, registrations, dispatcherType, reportedServices, reportedContracts);
+
+ using var cancellation = new CancellationTokenSource();
+ using ServiceProvider provider = build(registrations);
+ using IServiceScope first = provider.CreateScope();
+ using IServiceScope next = provider.CreateScope();
+ object dispatcher = first.ServiceProvider.GetRequiredService(dispatcherType);
+
+ Require(
+ ReferenceEquals(dispatcher, first.ServiceProvider.GetRequiredService(dispatcherType)) == SharesWithinScope(dispatcherLifetime),
+ label + " does not resolve a " + dispatcherLifetime + " dispatcher within one scope.");
+
+ Require(
+ !ReferenceEquals(dispatcher, next.ServiceProvider.GetRequiredService(dispatcherType)),
+ label + " shares dispatcher across scopes.");
+
+ var firstTrace = new ExecutionTrace();
+ var repeatedTrace = new ExecutionTrace();
+ var nextTrace = new ExecutionTrace();
+
+ Execute(first.ServiceProvider, firstTrace);
+ Execute(first.ServiceProvider, repeatedTrace);
+ Execute(next.ServiceProvider, nextTrace);
+
+ VerifyRecords(label, firstTrace.Entries, expectedNames, cancellation.Token, ordered);
+ VerifyRecords(label + " (repeated call)", repeatedTrace.Entries, expectedNames, cancellation.Token, ordered);
+ VerifyRecords(label + " (next scope)", nextTrace.Entries, expectedNames, cancellation.Token, ordered);
+
+ ScopeWitness firstWitness = first.ServiceProvider.GetRequiredService();
+ ScopeWitness nextWitness = next.ServiceProvider.GetRequiredService();
+
+ VerifyComponentIdentities(label, firstTrace, repeatedTrace, nextTrace, firstWitness, nextWitness);
+
+ first.Dispose();
+
+ Require(
+ firstTrace.Entries.All(entry => entry.Component.IsDisposed && entry.Scope.IsDisposed),
+ label + " did not dispose the first scope.");
+
+ Require(
+ nextTrace.Entries.All(entry => !entry.Component.IsDisposed && !entry.Scope.IsDisposed),
+ label + " disposed another scope's component.");
+
+ next.Dispose();
+
+ Require(
+ nextTrace.Entries.All(entry => entry.Component.IsDisposed && entry.Scope.IsDisposed),
+ label + " did not dispose the next scope.");
+
+ if (report)
+ Console.WriteLine(" Verified dispatcher, component and dependency identities, tokens, traversal and disposal.");
+
+ void Execute(IServiceProvider services, ExecutionTrace trace)
+ {
+ try
+ {
+ execute(services, trace, cancellation.Token).GetAwaiter().GetResult();
+ }
+ catch (Exception exception)
+ {
+ throw new InvalidOperationException(label + " failed.", exception);
+ }
+ }
+ }
+
+ ///
+ /// Runs on two libraries' registrations of the same workload.
+ ///
+ /// The workload description that follows each library name.
+ /// Required of both libraries, so a lifetime mismatch fails the pair.
+ public static void VerifyPair(
+ string label,
+ string[] expectedNames,
+ bool report,
+ ComparedLibrary first,
+ ServiceCollection firstServices,
+ Func firstExecute,
+ ComparedLibrary second,
+ ServiceCollection secondServices,
+ Func secondExecute,
+ bool ordered = true,
+ ServiceLifetime dispatcherLifetime = ServiceLifetime.Scoped)
+ where TFirst : notnull
+ where TSecond : notnull
+ {
+ Verify(first, firstServices, firstExecute);
+ Verify(second, secondServices, secondExecute);
+
+ void Verify(
+ ComparedLibrary library,
+ ServiceCollection services,
+ Func execute)
+ where TDispatcher : notnull
+ => VerifyScopes(
+ label: library.Name + " " + label,
+ registrations: services,
+ build: library.Build,
+ dispatcherType: typeof(TDispatcher),
+ execute: (provider, trace, token) => execute(provider.GetRequiredService(), trace, token),
+ expectedNames: expectedNames,
+ report: report,
+ ordered: ordered,
+ reportedServices: library.ReportedServices,
+ reportedContracts: library.ReportedContracts,
+ dispatcherLifetime: dispatcherLifetime);
+ }
+
+ internal static bool SharesWithinScope(ServiceLifetime dispatcherLifetime) => dispatcherLifetime switch
+ {
+ ServiceLifetime.Scoped => true,
+ ServiceLifetime.Transient => false,
+ _ => throw new ArgumentOutOfRangeException(nameof(dispatcherLifetime), dispatcherLifetime, "Dispatchers are scoped or transient."),
+ };
+
+ ///
+ /// Requires a request to return the shared workload result.
+ ///
+ public static async Task ExpectResult(Task operation)
+ {
+ int result = await operation.ConfigureAwait(false);
+
+ Require(result == BenchmarkWorkload.Expected, $"Request returned {result}, expected {BenchmarkWorkload.Expected}.");
+ }
+
+ ///
+ public static Task ExpectResult(ValueTask operation) => ExpectResult(operation.AsTask());
+
+ ///
+ /// Consumes a stream and checks its checksum, or reads only its first item when is set.
+ ///
+ public static async Task ReadStream(IAsyncEnumerable sequence, int count, bool early, CancellationToken token)
+ {
+ if (early)
+ {
+ await using IAsyncEnumerator enumerator = sequence.GetAsyncEnumerator(token);
+
+ Require(await enumerator.MoveNextAsync().ConfigureAwait(false), "Stream ended before its first item.");
+ Require(enumerator.Current == 0, "Stream returned the wrong first item.");
+
+ return;
+ }
+
+ int sum = await BenchmarkWorkload.Consume(sequence).ConfigureAwait(false);
+
+ Require(sum == count * (count - 1) / 2, "Stream returned the wrong checksum.");
+ }
+
+ private static void VerifyComponentRegistrations(string label, ServiceDescriptor[] components)
+ {
+ Require(
+ components.All(descriptor => descriptor.Lifetime == ServiceLifetime.Scoped),
+ label + " has a non-scoped component.");
+
+ bool hasUniqueRegistrations = components.GroupBy(descriptor => (descriptor.ServiceType,
+ descriptor.IsKeyedService ? descriptor.ServiceKey : null,
+ GetImplementationType(descriptor))).All(group => group.Count() == 1);
+
+ Require(hasUniqueRegistrations, label + " has duplicate component registrations.");
+ }
+
+ private static void VerifyComponentIdentities(
+ string label,
+ ExecutionTrace firstTrace,
+ ExecutionTrace repeatedTrace,
+ ExecutionTrace nextTrace,
+ ScopeWitness firstWitness,
+ ScopeWitness nextWitness)
+ {
+ foreach (ExecutionRecord entry in firstTrace.Entries)
+ {
+ ExecutionRecord repeated = repeatedTrace.Entries.Single(candidate => candidate.Name == entry.Name);
+ ExecutionRecord fresh = nextTrace.Entries.Single(candidate => candidate.Name == entry.Name);
+
+ Require(
+ ReferenceEquals(entry.Component, repeated.Component),
+ label + " changes " + entry.Name + " within a scope.");
+
+ Require(
+ !ReferenceEquals(entry.Component, fresh.Component),
+ label + " shares " + entry.Name + " across scopes.");
+
+ Require(
+ ReferenceEquals(entry.Scope, firstWitness) && ReferenceEquals(repeated.Scope, firstWitness),
+ label + " changes its scoped dependency.");
+
+ Require(
+ ReferenceEquals(fresh.Scope, nextWitness) && !ReferenceEquals(firstWitness, nextWitness),
+ label + " shares its scoped dependency across scopes.");
+
+ Require(!entry.Component.IsDisposed && !fresh.Component.IsDisposed, label + " disposed a live component.");
+ }
+
+ Require(!firstWitness.IsDisposed && !nextWitness.IsDisposed, label + " disposed a live dependency.");
+ }
+}
diff --git a/benchmarks/RequestFlow.Benchmarks.Core/Verification/ScopeVerification.cs b/benchmarks/RequestFlow.Benchmarks.Core/Verification/ScopeVerification.cs
new file mode 100644
index 0000000..3717cbe
--- /dev/null
+++ b/benchmarks/RequestFlow.Benchmarks.Core/Verification/ScopeVerification.cs
@@ -0,0 +1,70 @@
+using System.Reflection;
+using static RequestFlow.Benchmarks.Core.TraceAssertions;
+
+namespace RequestFlow.Benchmarks.Core;
+
+internal static class ScopeVerification
+{
+ public static ScopeLifetime For(Type benchmarkType)
+ => benchmarkType.GetCustomAttribute()?.Lifetime
+ ?? throw new InvalidOperationException($"Benchmark '{benchmarkType.Name}' has no [BenchmarkScope] attribute.");
+
+ public static void VerifyAfterCall(BenchmarkObservation observation, BenchmarkExpectation expectation)
+ {
+ ScopeLifetime lifetime = For(observation.BenchmarkType);
+
+ if (lifetime == ScopeLifetime.DirectHandler)
+ return;
+
+ IReadOnlyList records = observation.Trace ?? [];
+ string label = BenchmarkVerification.Label(observation);
+ bool childScopes = expectation.ChildScopeMethod == observation.Method;
+
+ Require(records.Count > 0, label + " recorded no scoped components.");
+
+ if (lifetime == ScopeLifetime.RetainedScope)
+ {
+ Require(!childScopes, label + " runs handlers in a child scope per send, so a retained scope cannot retain them.");
+ Require(Witnesses(records).Length == 1, label + " did not run every component in its retained scope.");
+ Require(records.All(record => !record.Scope.IsDisposed && !record.Component.IsDisposed),
+ label + " disposed a component of its retained scope.");
+
+ return;
+ }
+
+ int scopes = childScopes ? expectation.ChildScopes : 1;
+
+ Require(Witnesses(records).Length == scopes, $"{label} ran its components in {Witnesses(records).Length} scopes, expected {scopes}.");
+ Require(records.All(record => record.Scope.IsDisposed && record.Component.IsDisposed),
+ label + " completed before disposing its operation scope.");
+ }
+
+ public static void VerifyAcrossCalls(BenchmarkObservation first, BenchmarkObservation second, BenchmarkExpectation expectation)
+ {
+ ScopeLifetime lifetime = For(first.BenchmarkType);
+
+ if (lifetime == ScopeLifetime.DirectHandler)
+ return;
+
+ IReadOnlyList firstRecords = first.Trace ?? [];
+ IReadOnlyList secondRecords = second.Trace ?? [];
+ string label = BenchmarkVerification.Label(first);
+
+ if (lifetime == ScopeLifetime.RetainedScope)
+ {
+ Require(Witnesses(firstRecords).SequenceEqual(Witnesses(secondRecords)), label + " changed its retained scope between calls.");
+ Require(Components(firstRecords).SetEquals(Components(secondRecords)), label + " changed a scoped component between calls.");
+
+ return;
+ }
+
+ Require(!Witnesses(firstRecords).Intersect(Witnesses(secondRecords)).Any(), label + " reused an operation scope.");
+ Require(!Components(firstRecords).Overlaps(Components(secondRecords)), label + " reused a scoped component across operations.");
+ }
+
+ private static ScopeWitness[] Witnesses(IReadOnlyList records)
+ => records.Select(record => record.Scope).Distinct(ReferenceEqualityComparer.Instance).Cast().ToArray();
+
+ private static HashSet