Optimize SimpleInjector processor dispatch - #829
Conversation
Co-authored-by: AndreaCuneo <5227688+AndreaCuneo@users.noreply.github.com>
Co-authored-by: AndreaCuneo <5227688+AndreaCuneo@users.noreply.github.com>
Co-authored-by: AndreaCuneo <5227688+AndreaCuneo@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Optimizes Ark.Tools.Solid.SimpleInjector processor dispatch by caching compiled handler invokers per runtime message type, avoiding per-call MakeGenericType and dynamic binder overhead while preserving per-dispatch handler resolution (scopes/transients/decorators).
Changes:
- Replace
dynamic/per-call reflection dispatch with compiled invoker caches for request/query/command processors. - Add MSTest coverage for decorator execution order plus cancellation/exception propagation.
- Add a BenchmarkDotNet benchmark project and move the performance task doc from pending to completed (with recorded results).
Reviewed changes
Copilot reviewed 15 out of 15 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/Ark.Tools.Solid.SimpleInjector.Tests/SimpleInjectorProcessorTests.cs | New behavioral tests for decorated handlers + cancellation/exception behavior. |
| tests/Ark.Tools.Solid.SimpleInjector.Tests/Ark.Tools.Solid.SimpleInjector.Tests.csproj | New test project wiring for Solid.SimpleInjector. |
| tests/Ark.Tools.Solid.SimpleInjector.Tests/packages.lock.json | Lockfile for the new test project. |
| src/common/Ark.Tools.Solid.SimpleInjector/SimpleInjectorRequestProcessor.cs | Switch request dispatch to cached compiled invoker. |
| src/common/Ark.Tools.Solid.SimpleInjector/SimpleInjectorQueryProcessor.cs | Switch query dispatch to cached compiled invoker. |
| src/common/Ark.Tools.Solid.SimpleInjector/SimpleInjectorCommandProcessor.cs | Switch command dispatch to cached compiled invoker. |
| src/common/Ark.Tools.Solid.SimpleInjector/HandlerInvokerCaches.cs | New compiled-invoker cache implementation. |
| benchmarks/Ark.Tools.Benchmarks/Ark.Tools.Benchmarks.csproj | New BenchmarkDotNet benchmark project. |
| benchmarks/Ark.Tools.Benchmarks/Program.cs | BenchmarkDotNet entrypoint. |
| benchmarks/Ark.Tools.Benchmarks/ProcessorDispatchBenchmarks.cs | Benchmarks comparing reflection/dynamic vs cached invoker dispatch. |
| benchmarks/Ark.Tools.Benchmarks/packages.lock.json | Lockfile for the new benchmark project. |
| docs/performance/pending/001-solid-simpleinjector-dispatch.md | Remove pending task doc (moved to completed). |
| docs/performance/completed/001-solid-simpleinjector-dispatch.md | Completed task doc with benchmark summary + decision record. |
| Directory.Packages.props | Add BenchmarkDotNet version pin. |
| Ark.Tools.slnx | Include new benchmark + new test project in the solution. |
Suppressed comments (4)
src/common/Ark.Tools.Solid.SimpleInjector/HandlerInvokerCaches.cs:59
- This ExecuteAsync method returns the Task directly. Repo guidance requires async methods to use async/await (even for a single await) for stacktrace clarity in production debugging.
public static Task<TResponse> ExecuteAsync(Container container, IRequest<TResponse> request, CancellationToken cancellationToken)
{
var invoker = _invokers.GetOrAdd(request.GetType(), static requestType => CreateInvoker(requestType));
return invoker(container, request, cancellationToken);
}
src/common/Ark.Tools.Solid.SimpleInjector/HandlerInvokerCaches.cs:96
- This ExecuteAsync method returns the Task directly. Repo guidance requires async methods to use async/await (even for a single await) for stacktrace clarity in production debugging.
public static Task ExecuteAsync(Container container, ICommand command, CancellationToken cancellationToken)
{
var invoker = _invokers.GetOrAdd(command.GetType(), static commandType => CreateInvoker(commandType));
return invoker(container, command, cancellationToken);
}
benchmarks/Ark.Tools.Benchmarks/ProcessorDispatchBenchmarks.cs:78
- The reflection/dynamic benchmark path awaits without ConfigureAwait(false), while the cached path uses it. Keeping both consistent avoids context-capture noise and matches the repo’s async style.
var handlerType = typeof(IRequestHandler<,>).MakeGenericType(_request.GetType(), typeof(int));
dynamic handler = _container.GetInstance(handlerType);
return await handler.ExecuteAsync((dynamic)_request);
}
benchmarks/Ark.Tools.Benchmarks/ProcessorDispatchBenchmarks.cs:92
- The reflection/dynamic benchmark path awaits without ConfigureAwait(false), while the cached path uses it. Keeping both consistent avoids context-capture noise and matches the repo’s async style.
var handlerType = typeof(ICommandHandler<>).MakeGenericType(_command.GetType());
dynamic handler = _container.GetInstance(handlerType);
await handler.ExecuteAsync((dynamic)_command);
}
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Eliminates per-dispatch
MakeGenericTypeand dynamic binder work in Solid SimpleInjector processors while preserving handler resolution and decorators. Task 001 is documented with benchmark results and moved to completed.Coverage and measurement
Generator decision