Repository navigation
Fix/v3.2 correctness - #2
Merged
Merged
Conversation
…nces CompiledPipelineFactory cached resolved IPipelineBehavior instances in a static dictionary keyed on request type. A scoped behavior was therefore resolved once, from the first dispatch scope, and reused for the lifetime of the process. Any behavior holding a DbContext, unit of work or other scoped dependency operated on a disposed scope from the second dispatch onwards. Because the cache was static it also leaked behavior instances between DI containers in the same process, which breaks tests that build more than one host. Changes: - CompiledPipelineFactory is no longer static. It is instantiated once per CompiledRequestInvokerDispatcher, and therefore once per container, so the pipeline delegate caches no longer outlive the container that populated them or capture another container's handler registry. - Deleted the behavior instance cache. All three call sites now resolve behaviors through ResolveBehaviors, which uses the provider passed into the dispatch. That is the dispatch scope under RoutyaDispatchScope.Scoped. - Cache only whether a request type has any behavior registered, not the instances. Registrations are immutable once the container is built, so that fact is safe to cache and keeps the no behavior fast path free of a GetServices call. - Removed three Expression.Parameter locals that were created but never used. Handler resolution is deliberately left untouched so this change can be reviewed on its own. Adds four regression tests, all of which failed before this change: behaviors resolve once per dispatch (async and sync), never observe a disposed dependency, and do not leak between service providers. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…oot provider CompiledNotificationDispatcher built its handler wrappers before creating the dispatch scope. For handlers that were not described in the registry, which is every handler registered directly against IServiceCollection, it called GetServices on the root provider and then cached the resolved instances for the lifetime of the process. That had two consequences for the registration style the README documents as supported: - Under scope validation, which is the ASP.NET Core Development default, the first publish threw "Cannot resolve scoped service ... from root provider" and the notification never reached its handlers. - With validation disabled, the handler was captured as a root singleton and its scoped dependencies were reused after their scope was disposed. Changes: - PublishAsync now creates the dispatch scope first and passes that provider down, so every resolution happens inside the scope. - Handlers outside the registry are resolved through GetServices on the dispatch scope on every publish and invoked directly. They cannot be resolved by concrete type, because registering INotificationHandler<T> against an implementation does not register the implementation itself. - Registry backed handlers keep their existing cached wrappers, which already deferred Scoped and Transient resolution correctly. Singleton instances are still resolved once from the root provider. - Removed the fallback write into the shared notification registry. It recorded every discovered handler as Transient regardless of its real lifetime, and nothing ever read it back, so it was misleading rather than an optimisation. Removing it also takes a lock off the publish path. - Notification types with no handlers are recorded so later publishes return without creating a scope. - Deleted NotificationHandlerWrapperWithInstance, now unused. Adds five tests covering publish under scope validation, resolution per publish for both sequential and parallel strategies, no reuse of a disposed dependency, and a guard that the registry path still resolves per publish. The first four failed before this change. Also corrects RegistryCachingTests: the notification test asserted Assert.True(true) under a name describing the registry write removed here. It now asserts the handler is invoked on every publish. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Routya.SourceGen.Test runs the generator over its own compilation, so a generator defect that emits invalid code breaks that project's build rather than producing a failing test. That makes the four known generator defects impossible to express as tests. This adds Routya.SourceGenerators.Test, which drives HandlerRegistrationGenerator against source text held in the test and reports three things separately: - errors in the test's own input, so a broken test is distinguishable from a generator defect - diagnostics the generator reported, including CS8785 when it throws - errors in the compilation once the generated sources are added GeneratorRunResult.AssertCompiles asserts all three are clean, which is the assertion the upcoming generator fixes need. Includes three sanity tests covering an async request handler, two notification handlers, and a deliberately invalid input. Wired into the build-and-test workflow. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…rings The generator had four separate type name renderers, all of which built a name by concatenating ContainingNamespace and Name. That is wrong in several ways, and each way broke the consumer's build: - Containing types were discarded, so a nested request type such as Features.CreateOrder.Command was emitted as Features.Command and the consumer got CS0234. This affects the feature folder convention, where the request and its handler are nested in one type per feature. - Type arguments that were not named types could not be rendered. A response of List<byte[]> was emitted as List<T>, giving CS7003 unbound generic name and a cascade of follow on errors. - HandlerDescriptor cast request and response type arguments to INamedTypeSymbol. An array response such as IRequest<string[]> is an IArrayTypeSymbol, so the cast threw InvalidCastException. Roslyn surfaced this as CS8785 and the generator then contributed nothing at all, leaving the user with unexplained CS0234 errors everywhere. - Names had no global:: prefix, so a user type that shadowed a framework type broke the generated code. Changes: - Added SymbolNames.ToGeneratedName, a single renderer built on Roslyn's own fully qualified display format, which handles nested types, arrays, constructed generics, keyword aliases and nullable annotations, and prefixes global::. Replaced all four hand built renderers with it. - HandlerDescriptor.RequestType and ResponseType are now ITypeSymbol rather than INamedTypeSymbol, removing the casts that threw. - Handler interfaces are now matched on namespace, name and arity through SymbolNames.IsRoutyaInterface, rather than by string prefix. The prefix test would also have matched an unrelated user interface whose name began with IRequestHandler in the Routya.Core.Abstractions namespace. Adds seven tests covering array responses, arrays nested inside generics, nested request and notification types, generic responses, the global namespace, and a user type shadowing a framework type. Four failed before this change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…andleAsync
The registration emitter declared a synchronous member on the generated
interface for handlers implementing IRequestHandler:
TResponse Send(TRequest request);
The dispatcher emitter ignored HandlerDescriptor.IsAsync and always emitted
an asynchronous SendAsync whose body called handler.HandleAsync. A
synchronous handler has no such method, so any project with a synchronous
handler failed to build with two errors:
CS0535: 'GeneratedRoutya' does not implement interface member
'IGeneratedRoutya.Send(Ping)'
CS1061: 'PingHandler' does not contain a definition for 'HandleAsync'
No source generator project in the repository used a synchronous handler,
which is why CI did not catch this.
Changes:
- EmitRequestDispatchMethod now branches on IsAsync. Asynchronous handlers
get SendAsync calling HandleAsync, synchronous handlers get Send calling
Handle, which matches what the interface declares in each case.
- Behavior chain construction and handler resolution are shared between the
two, so the pipeline semantics cannot drift apart again.
- Behaviors are materialized into an array once. The previous code called
Any() and then Reverse() on the same lazy sequence, enumerating it twice.
- The chain is walked by index rather than through Reverse(), which removes
an allocation from the behavior path.
Where behaviors are present, the synchronous Send waits for the chain,
because IPipelineBehavior is asynchronous. That matches the documented
behaviour of IRoutya.Send. Blocking on an asynchronous chain is a wider
design question that applies to the runtime dispatcher too, so it is left
alone here rather than changed on one path only.
Adds five harness tests proving generated code compiles for a synchronous
handler, with a behavior, alongside an async handler, and with a nested
request type, plus three runtime tests in Routya.SourceGen.Test proving a
synchronous handler is registered, dispatches correctly, and runs behaviors.
All five harness tests failed before this change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… members The generator only considered ClassDeclarationSyntax nodes whose symbol was declared public. Two common shapes were therefore skipped in silence, with no diagnostic and no registration: - "internal sealed class Handler", the usual Clean Architecture convention, where the request is public contract and the handler is an implementation detail - handlers declared as records A skipped handler is not registered and gets no typed member, so the failure only appears later as a missing service or a missing method. Changes: - The syntax predicate now matches TypeDeclarationSyntax, so records and structs are considered alongside classes. Interfaces remain excluded by the existing IsAbstract check. - The public only accessibility filter is removed. An internal handler is referenced solely from generated method bodies in the same assembly, so registration and dispatch both work. - Added SymbolNames.IsExternallyVisible, which walks containing types and type arguments, and HandlerDescriptor.SupportsTypedDispatch built on it. IGeneratedRoutya and GeneratedRoutya are public, so they cannot mention a type that is not externally visible without CS0051 or CS0050. Where the request or notification type is internal, the handler is still registered but gets no typed member, and the new ROUTYA005 warning names the handler and the offending type and points at runtime IRoutya dispatch. That was preferred over flipping the generated types to internal, which would change the consumer's public API surface based on one handler's accessibility. Adds seven harness tests covering internal request, notification and nested handlers, record handlers, the ROUTYA005 cases, and a check that a fully public handler reports nothing. Adds two runtime tests proving an internal handler is registered and dispatches. Six failed before this change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both benchmark entry points declared Main without parameters and called BenchmarkRunner.Run without passing args, so BenchmarkDotNet's command line configuration was ignored. Selecting a job, filtering benchmarks or changing the exporter all required editing code first. Main now takes args and passes them through. Also adds Routya_ScopedBehaviors_Send and Routya_ScopedBehaviors_SendAsync to the request benchmarks. Every existing case registers its pipeline behaviors as Singleton, so none of them exercised the cost of constructing a behavior per dispatch scope. That is the configuration most affected by resolving behaviors per dispatch rather than caching the instances, and it had no coverage at all. Measured on the new cases, Scoped behaviors cost a consistent 272 B per dispatch over Singleton behaviors on both the sync and async paths. That is two behavior instances per scope plus the array, which is inherent to the Scoped lifetime rather than specific to this implementation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both benchmark tables stated the comparison was against MediatR 13.1.0. Routya.Benchmark and Routya.Notification.Benchmark both reference MediatR 12.5.0, confirmed in the project files, in project.assets.json for both projects, and in the product version of the built MediatR.dll. The distinction matters beyond a version number: 12.5.0 is the Apache 2.0 release, whereas 13.x ships under the dual licence introduced in July 2025. A reader choosing between the two is not looking at the same library. This corrects the stated version only. The figures themselves still need regenerating, which is tracked separately. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…de another
The notification emitter special cased exactly two handlers, starting both
tasks and then awaiting them one after another to avoid the array that
Task.WhenAll allocates:
var task0 = handler0.Handle(notification, cancellationToken);
var task1 = handler1.Handle(notification, cancellationToken);
await task0.ConfigureAwait(false);
await task1.ConfigureAwait(false);
That is not equivalent to awaiting both. If task0 faults, the first await
rethrows straight away and task1 is never awaited, so its exception is never
observed and the caller resumes while that handler is still running. Two
handlers is the most common fan out, so this was the likeliest case to hit.
Every count above one now goes through Task.WhenAll, which observes all
tasks and waits for all of them before propagating. A single handler is
still awaited directly. The cost is one array per publish on a path that was
previously silently losing exceptions.
Proven by a runtime test in Routya.SourceGen.Test: with two handlers, one
faulting asynchronously and one taking 75ms, the publish must still throw
and the slow handler must have completed by the time it does. That test was
confirmed to fail against the previous emitter before this change. Three
harness tests pin the generated shape for one, two and three handlers.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
When a handler was not described in the registry, the pipeline resolved it through its interface and then wrote the discovered concrete type into the registry, described as an optimisation that later calls would benefit from. They never did. The registry lookup happens once, when the pipeline delegate is built, and its result is captured in that delegate's closure. The delegate is then cached per request type for the lifetime of the dispatcher, so it never re-reads the registry it just wrote. Every dispatch took the same interface resolution path as the first. Making the write effective instead of removing it would have introduced a real failure. Registering IAsyncRequestHandler<,> against an implementation does not register the implementation type, so the registry path's GetRequiredService(concreteType) would throw for exactly the handlers this code was discovering. The write also recorded every handler as Transient regardless of its real lifetime. So the write is removed, along with the lock it took on a dictionary shared across the container, and the now unused needsFallbackCheck flags. Behaviour is unchanged; only dead work is gone. README corrected to match. It claimed discovered handlers were added to the registry, that second and subsequent calls used registry optimised dispatch, and that this was around 28% faster for notifications. It now states that handlers registered directly against IServiceCollection are resolved through their interface on every dispatch, explains why that is required for Scoped handlers, and points at the AddRoutya*Handler methods and assembly scanning for the registry path. Adds three tests covering async, sync and notification handlers registered by interface only, with the implementation type deliberately unregistered, asserting dispatch keeps working across repeated calls. These guard against reintroducing resolution by concrete type. Also renames the existing RegistryCachingTests case, whose name described the removed behaviour. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
RegisterRoutyaHandlersFromAssembly called assembly.GetTypes() directly. That throws ReflectionTypeLoadException whenever any single type in the assembly fails to load, which happens in ordinary applications when a scanned assembly references something that is not deployed, such as an optional provider package. The exception escaped AddRoutya, so one unloadable type anywhere in a scanned assembly failed application startup, even when every Routya handler in that assembly had loaded without trouble. ReflectionTypeLoadException carries the types that did load, with nulls in place of the ones that did not, so scanning now continues with those. Adds two tests using an Assembly subclass that reproduces the failure. Assembly.GetTypes is virtual, so this needs no broken assembly on disk. Both failed with an escaping ReflectionTypeLoadException before this change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Five internal files were referenced by nothing: Dispatchers/Requests/CompiledRequestInvokerFactory.cs Dispatchers/Notifications/CompiledNotificationInvokerFactory.cs Extensions/ExpressionExtension.cs Extensions/Configurations/ExtensionConstant.cs Dispatchers/Configurations/CompiledConstant.cs The first three built and compiled expression trees. The last two existed only to supply parameter names to them. Nothing in the library, the tests or the demos called into any of them. Both dispatchers build plain closures and resolve handlers through the container instead. All five were internal, so removing them changes no public API. They held every remaining .Compile() call in Routya.Core, and the last using of System.Linq.Expressions went with them. The library now has no expression tree construction or compilation on any path, which matters well beyond tidiness: runtime expression compilation is the usual obstacle to Native AOT and trimming. Whether Routya.Core is actually AOT clean is a separate question that needs verifying rather than assuming, but the most likely blocker is now gone. Behaviour is unchanged. Routya.Core builds across all five target frameworks with the same nine pre-existing warnings, and all 44 tests pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…t in use Both dispatchers documented themselves as using compiled expression trees. Neither does, and neither did before the expression code was removed: they build plain closures and resolve handlers through the container. The notification dispatcher went further and claimed handlers were "compiled on first use". These docs ship in the NuGet package and drive IntelliSense, so they were telling users something untrue about how their dispatch works, and about the library's suitability for trimming and Native AOT. Both now describe what actually happens, including which registration styles get direct resolution and which are resolved through their interface, and state plainly that no expression trees are built or compiled. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every Routya allocation figure in both benchmark tables was wrong. The offsets were consistent, which rules out measurement drift and points to a real reduction in the library between when the tables were produced and now: Send rows published 200 B higher than measured SendAsync rows published 240 B higher than measured Notification published 32 B higher on every row Confirmed by measuring the unmodified main branch as well as this one, so the tables were already stale before any of the recent work. MediatR's own figures were correct and are unchanged. Corrected Gen0 and Allocated on both tables, and the memory percentages derived from them. Several were understating Routya: Singleton sequential 56% less memory -> 64% (160 B vs 440 B) Transient sequential 45% less memory -> 53% (208 B vs 440 B) Singleton parallel 29% less memory -> 36% (280 B vs 440 B) Singleton request 50% less memory -> 20% (808 B vs 1016 B) The last of those was overstated rather than understated. Mean and Ratio are deliberately left alone. They came from different hardware and a different runtime, and this machine cannot produce publishable timings: BenchmarkDotNet flagged multimodal distributions on most rows and the unchanged MediatR baseline drifted around 9% between runs. Both tables now carry a note saying which columns were re-measured and which are indicative, rather than presenting all four columns as equally current. Also documents that Scoped pipeline behaviors cost a further 272 B per dispatch over Singleton ones, measured consistently across two runs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
[Unreleased] was empty despite fourteen commits on this branch, six of them behaviour fixes. Users cannot act on a release they cannot read. Entries are grouped by package, since which defects could affect a given user depends entirely on whether they use Routya.Core's runtime dispatch or the source generator, and those two sets barely overlap. Adds an Upgrade notes section covering the three ways this release can change behaviour for an existing project: internal handlers that were previously skipped are now registered and may duplicate a manual registration, the new ROUTYA005 warning can fail a build using TreatWarningsAsErrors, and Scoped pipeline behaviors are now correctly built per dispatch scope at a cost of roughly 272 B per dispatch. Left under [Unreleased] rather than assigned a version number. Nothing here breaks the public API, so 3.2.0 fits, but that is a release decision. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Matches the change already made to the other two benchmark entry points. Main declared no parameters and BenchmarkRunner.Run was called without args, so BenchmarkDotNet's command line configuration was ignored and selecting a job or filtering benchmarks required editing code. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Routya.Core now compiles with zero IL trimming and AOT analyzer warnings, and a Native AOT binary using the source generator has been confirmed to build and run. Changes: - Annotated the THandler type parameter on AddRoutyaRequestHandler, AddRoutyaAsyncRequestHandler and AddRoutyaNotificationHandler with DynamicallyAccessedMembers(PublicConstructors). Without it the trimmer could remove a handler's constructor, giving 18 IL2087 warnings - Split AddRoutya into two overloads. The existing signature took optional assemblies, so marking it RequiresUnreferencedCode would have warned every caller including those who never scan. There is now a clean AddRoutya(services, configure) and a separate scanning overload carrying the annotation. Overload resolution prefers the clean one in normal form, so existing source and existing compiled callers both keep working - Annotated the scanning path, which cannot be made trim safe, so callers get a build time warning naming the alternatives rather than a handler silently disappearing at runtime - Added IsAotCompatible to the net8.0, net9.0 and net10.0 targets so the analyzers run on every build and a regression fails here rather than in a consumer's published application - Added internal copies of the trimming attributes for the netstandard targets, which predate them, so one set of signatures serves all five target frameworks Verified by publishing a Native AOT console application against the source generator, with an async handler, a synchronous handler returning decimal, two pipeline behaviors and a notification with two handlers. ILCompiler produced no IL warnings and the 2.4 MB native binary ran correctly. One limitation found, which belongs to Microsoft.Extensions.DependencyInjection rather than to Routya: open generic service registrations cannot be constructed under Native AOT when a type argument is a value type. Registering a behavior as typeof(IPipelineBehavior<,>) therefore throws at runtime for a request whose response is, for example, decimal or int. Adding a closed registration alongside the open generic one does not help, because GetServices enumerates every registration for the service type. Behaviors must be registered closed, with no open generic registration present. Documented separately.
…e AOT work Routya.Aot.Benchmark runs the source generated dispatch path under both the ordinary JIT runtime and Native AOT, using BenchmarkDotNet's NativeAot80 toolchain, so the two can be compared directly. Three deliberate scoping decisions, each forced by a limitation found while verifying AOT support: - Only the source generated path is measured. The runtime dispatcher depends on assembly scanning, which is now annotated RequiresUnreferencedCode precisely because it is not AOT compatible - MediatR is excluded rather than used as a baseline. It resolves handlers reflectively, so under AOT the question is not which is faster - Pipeline behaviors are registered closed rather than as open generics, because Microsoft's DI container cannot construct an open generic service under AOT when a type argument is a value type, which the decimal benchmark would hit immediately Also fixes three warnings introduced by the previous commit: - CS1572 and CS1734: the clean AddRoutya overload still documented a scanAssemblies parameter it no longer has after the overload split - CS8618: the trimming attribute polyfill declared a non-nullable Url property that was never set. It was unused, so it is removed A forced full rebuild now reports CS8632 as the only warning code, all of which predate this branch, and no IL trimming or AOT warnings.
Routya is a maintained dependency in customer projects, so performance
regressions need to be caught by CI rather than noticed later. Timings
cannot do that on a shared runner: measured locally, the MediatR baseline in
these benchmarks drifted by a third between runs on identical code, which
dwarfs any regression worth catching. Comparing one CI run against a
previous one is worse still, because each lands on different hardware.
Allocated bytes do not have that problem. They are deterministic, so they
hold on any machine, any CPU and either GC mode, and they are what the
README actually advertises.
Adds eight allocation budget tests, five for the runtime dispatcher and
three for the source generated one, covering Send, SendAsync and
PublishAsync across Root and Scoped configurations. Each measures bytes per
operation using the per thread allocation counter, after a warm up that
excludes the one off cost of building the cached dispatch delegate.
Budgets were measured on net8.0 and carry roughly 25% headroom:
runtime, Root scope, Singleton handlers
Send 104 B, budget 128
SendAsync 208 B, budget 256
PublishAsync, 2 handlers 32 B, budget 64
runtime, Scoped
SendAsync 544 B, budget 640
PublishAsync, 2 handlers 392 B, budget 480
source generated
SendAsync 200 B, budget 256
Send 56 B, budget 96
PublishAsync, 2 handlers 168 B, budget 224
Verified to fail as intended: lowering one budget produced
"allocated 208 B per operation, budget is 16 B".
Also removes the benchmark run from the build workflow. It ran BenchmarkDotNet
on every push to main and develop. It passed --job short, but until the
benchmark entry points were changed to accept command line arguments that
flag was silently discarded, so CI had been running full benchmark jobs and
publishing numbers nobody read. The project is still built, to keep it
compiling, but no longer executed.
The same workflow ran only Routya.Test, and only behind a Test-Path guard
that would silently skip it if the path ever changed. It now runs all three
test suites unconditionally, matching build-and-test.yml.
… parallel PublishParallelAsync created one dispatch scope and then started every handler against it concurrently. Any scoped service that two handlers both resolved was therefore the same instance, in use by both at once. For a DbContext that means "A second operation was started on this context before a previous operation completed", which appears under load rather than in tests, in exactly the fan out scenario parallel publishing is chosen for. Parallel dispatch under RoutyaDispatchScope.Scoped now creates a scope per handler. Sequential dispatch deliberately keeps a single shared scope: handlers run one after another so there is no concurrency hazard, and sharing is useful when a later handler commits work an earlier one staged. The two strategies now differ on purpose, and both behaviours are pinned by tests. Handlers registered directly against IServiceCollection need extra care. They can only be reached through their interface, and resolving the whole enumerable once would bind every instance to a single scope, which is the thing being avoided. Each is therefore resolved inside its own scope, at the cost of one extra resolution of the enumerable per handler. Handler counts are small in practice, and the registry path avoids this entirely. The cost is measured rather than estimated: parallel publish with two Scoped handlers goes from 512 B to 824 B per publish, which is the second scope. An allocation budget test records that figure so it cannot drift unnoticed. Sequential publish is unchanged at 392 B. Adds four tests: parallel gives each handler its own scope, via both the registry and plain DI registration; repeated parallel publishes keep every scope distinct; and sequential still shares one scope. The three parallel tests failed before this change, and the sequential one passed, confirming the existing shared scope behaviour was already correct there.
…ations The XML documentation quoted twenty one hard coded nanosecond figures across six files, for example "Performance: ~334ns for Singleton handlers". These ship in the NuGet package and drive IntelliSense, so every consumer sees them. They were measured once on different hardware and never revisited, and the machine used for the recent runs produces figures roughly 20% higher for the same code. Absolute timings cannot be stated in documentation that is read on someone else's machine. They are replaced with allocation figures, which are deterministic and hold anywhere, taken from the allocation budget tests added in 5d62d2b: Send, Singleton handler, Root scope about 104 B SendAsync, Singleton handler, Root scope about 208 B SendAsync, Scoped handler about 544 B PublishAsync, Singleton, Root scope about 32 B PublishAsync, Scoped about 392 B PublishParallelAsync, Scoped about 824 B Where no measurement exists, for example Transient lifetimes, the claim is described qualitatively rather than given an invented number. Also documents the notification scope semantics introduced in d90c4bf on both IRoutya.PublishAsync and IRoutya.PublishParallelAsync: sequential shares one dispatch scope so a later handler can commit work an earlier one staged, parallel gives each handler its own because concurrent handlers must not share a DbContext. Each points at the other so the difference is discoverable from either.
The README said nothing about trimming or Native AOT, despite Routya.Core now building with the analyzers enabled and producing no IL warnings, and despite a Native AOT binary having been verified to run. It also said nothing about the scope behaviour introduced in d90c4bf, which users need to know because it differs between the two publish methods. Adds a Trimming and Native AOT section covering what is supported, and two things a user would otherwise discover the hard way: - Microsoft's DI container cannot construct an open generic service under Native AOT when a type argument is a value type, so a behaviour registered as typeof(IPipelineBehavior<,>) throws for any request returning int, decimal, bool, a Guid or an enum. Adding a closed registration alongside the open generic one does not help, because GetServices enumerates every registration and still tries to construct the open generic. This is a DI container limitation rather than a Routya one, but it bites Routya users - Native AOT is roughly three to four times slower per dispatch than the JIT, because it gives up tiered compilation and dynamic profile guided optimisation. It buys startup time, binary size and no runtime dependency. Stating this plainly avoids implying AOT is the fast path Adds a table contrasting the two publish methods: sequential shares one dispatch scope so handlers can share scoped state, parallel gives each handler its own so concurrent handlers cannot collide on a DbContext. Also removes "Streaming support" from the feature list. It was listed as a feature but is IRequest<IAsyncEnumerable<T>>, which the demo itself describes as a workaround, with first class IStreamRequest support still unimplemented. Claiming it invites a bad first impression. The same claim remains in RELEASE_NOTES_V3.md, which is a historical document.
[Unreleased] was written at f3c357e and predates the parallel publish fix, the trimming and AOT work, the allocation budget tests, the CI change and the two documentation passes. Adds to Fixed: PublishParallelAsync handing every handler the same scoped dependency, which produced concurrent DbContext use under load. Adds to Added: trimming and Native AOT support, and the allocation budget tests. Adds to Changed: the AddRoutya overload split, and the removal of the BenchmarkDotNet run from CI along with the test step that ran only one suite behind a guard that could silently skip it. Adds to Documentation: the XML timing figures replaced with allocations, the README Trimming and Native AOT section, the notification scope semantics, and the removal of the overstated streaming claim. Adds two upgrade notes, both of which can change behaviour for an existing project: parallel notification handlers can no longer share scoped state with one another, and open generic pipeline behaviour registrations throw under Native AOT for value type responses.
Routya advertised streaming as a feature while the demo it pointed at described the same thing as a workaround. Both statements were true of IRequest<IAsyncEnumerable<T>>, which is why the claim had to go one way or the other. It is now a real feature. The old shape returned a task that handed back a sequence, so the pipeline completed the moment the sequence was handed over, before a single item had been produced. A behavior wrapped around it could not observe items, could not catch an exception thrown part way through, and timed only the handover. That is not streaming in any useful sense. Adds: - IStreamRequest<TResponse>, IStreamRequestHandler<TRequest, TResponse>, IStreamPipelineBehavior<TRequest, TResponse> and StreamHandlerDelegate - IRoutya.CreateStream<TRequest, TResponse>, backed by CompiledStreamDispatcher - AddRoutyaStreamRequestHandler, which records the handler in the registry so dispatch resolves it directly - A generated CreateStream member per stream handler on IGeneratedRoutya, so source generator users get it with no generic arguments at the call site Stream behaviors wrap the enumeration itself, so they see every item and any failure. Items are produced lazily and nothing is buffered. Scope lifetime needed care. The dispatch scope is declared inside the async iterator, which ties its disposal to the iterator's own disposal rather than to the call that starts enumeration. It therefore survives the whole stream and is released when enumeration completes or the consumer abandons it, so a handler can hold a DbContext across the stream. Each CreateStream call gets its own scope. Routya.Core references Microsoft.Bcl.AsyncInterfaces on the netstandard2.0 target only, which has no IAsyncEnumerable<T> of its own. The API is identical across all five target frameworks. Adds 10 runtime tests covering ordering, laziness, absence of buffering, behaviors observing every item, behaviors observing a mid stream exception, behavior ordering, scope lifetime across enumeration, scope isolation between calls, cancellation part way through, and the missing handler error. Adds 6 generator tests and 3 further runtime tests for the generated member. Converts Routya.WebApi.SourceGen.Demo to the new API and removes the workaround comments. The streaming endpoint now returns the sequence directly rather than awaiting a task to obtain it. Note for anyone implementing IRoutya themselves, such as a test double: the interface has gained a member and those implementations need updating. Consumers are unaffected.
…kage Routya.Events is upcoming work with no implementation, only a placeholder Class1 and a description copied from Routya.Core. It nonetheless had GeneratePackageOnBuild enabled, so every build wrote a .nupkg carrying the real Routya.Events package id. Both 1.0.0 and 2.0.0 artefacts were sitting in bin. Nothing in CI pushes it: dotnet.yml packs and pushes only Routya.Core, Routya.SourceGenerators and Routya.Package. But a NuGet version, once published, can only be delisted and never withdrawn, so any process that globbed for nupkgs would have permanently squatted the id with an empty assembly. Marked IsPackable=false until there is something to ship. The project still builds, so the placeholder cannot rot.
… stray nul file IRoutya.CreateStream had no worked example. The source generator demo covers the generated CreateStream member, but the runtime dispatcher path was undemonstrated, and it is the one where scope lifetime across an enumeration actually matters. Adds ExportProductsRequest, a Scoped ExportProductsHandler and a GET /api/products/stream endpoint to Routya.WebApi.Demo. The handler injects AppDbContext directly rather than creating its own scope, and returns AsAsyncEnumerable() so rows are streamed from the database instead of materialised into a list. ASP.NET Core writes each product to the response as it arrives, so memory stays flat however many products match. That combination is the point of the example: under RoutyaDispatchScope.Scoped the dispatch scope lives for the whole enumeration, so the DbContext is still usable on the last item as well as the first, and the scope is released when the response finishes or the client disconnects part way through. Also removes the stray nul file from the working directory. It was never tracked, so nothing changes in history.
Adding CreateStream to IRoutya was recorded as a breaking change for anyone implementing the interface, but DefaultRoutya's public constructor also gained an IRoutyaStreamDispatcher parameter and that was missed. Resolving IRoutya from the container is unaffected; only direct construction breaks.
…n failure
The allocation budget tests printed their measured figure inside the assert
message, so it appeared only when a budget was exceeded. On a green run the
numbers were invisible, which meant a pipeline could confirm nothing had
regressed but could not tell you what the code actually allocates on that
hardware.
Each test now reports its figure two ways:
- To the test output, so it appears in the console log
- To GITHUB_STEP_SUMMARY when running under GitHub Actions, which renders as
a table on the workflow run page
Both build workflows now run the two suites carrying these tests with
"console;verbosity=detailed", since the default logger discards test output
on a passing run.
Verified locally by setting GITHUB_STEP_SUMMARY and running both suites,
which produced:
Routya.Core runtime dispatch
Send, Singleton 104 B (budget 128)
SendAsync, Singleton 208 B (budget 256)
PublishAsync, 2 Singleton 32 B (budget 64)
SendAsync, Scoped 544 B (budget 640)
PublishAsync, 2 Scoped 392 B (budget 480)
PublishParallelAsync, 2 Scoped 824 B (budget 1024)
Routya source generated dispatch
Send 56 B (budget 96)
SendAsync 200 B (budget 256)
PublishAsync, 2 handlers 168 B (budget 224)
Bumps Routya.Core, Routya.SourceGenerators and Routya.Package from 3.1.0 to 4.0.0, and closes the changelog into a dated 4.0.0 release. A major version rather than 3.2.0, because two public API changes are breaking, both narrow: IRoutya gained CreateStream, which affects anyone implementing the interface such as a hand written test double, and DefaultRoutya's public constructor gained an IRoutyaStreamDispatcher parameter. Consumers resolving IRoutya from the container need no source change. README corrections, all of which were wrong before this release rather than made wrong by it: - Install instructions told people to use Routya.SourceGenerators 3.0.0 and Routya.Core 2.0.0. Both now say 4.0.0, and the meta package is documented - The banner claimed "46% faster" on notifications, from a benchmark set measured on different hardware. Replaced with the allocation figures, which are deterministic and asserted by the budget tests on every build: 272 B per notification publish against MediatR's 600 B, and 976 B per request against 1008 B - The v2.0.0 section still advertised auto-caching of discovered handlers, which never worked and has been removed - Release Notes linked to RELEASE_NOTES_V3.md, which still carries the 46% claim. Now links to the changelog, which is maintained - Added a Breaking Changes in v4.0.0 section covering both API breaks and the three behaviour changes The same 46% claim appeared in Routya.SourceGenerators/README.md and Routya.Package/README.md, both of which ship inside the NuGet packages. Replaced with allocation figures and an explanation of why timings are not published. The generator README also now documents the generated CreateStream member. Verified by packing: Routya.Core.4.0.0.nupkg and Routya.4.0.0.nupkg build, and the new Microsoft.Bcl.AsyncInterfaces reference appears only in the .NETStandard2.0 dependency group, so no other target gains a dependency.
The pipeline succeeded but reported four categories of warning. All are now resolved, and every shipping and test project builds with zero warnings. RS2008, analyzer release tracking, five occurrences per build job. Added AnalyzerReleases.Shipped.md and AnalyzerReleases.Unshipped.md to Routya.SourceGenerators and wired them in as AdditionalFiles. This is the intended fix rather than a suppression: the release tracking files document every ROUTYA diagnostic id and severity in one place, so an accidental id change or severity change becomes visible in review. CS8632, nullable annotations outside an annotations context, ten occurrences. The source is shared across all five target frameworks and uses nullable reference annotations throughout, but netstandard2.0 had the annotation context off. That target now uses Nullable=annotations, which accepts the syntax without turning on flow analysis, so the shared source compiles cleanly without the null state warnings a pre nullable target would otherwise raise. xUnit1031, blocking task operations in test methods, eight occurrences. Suppressing would have been wrong, but so would the analyzer's suggested fix: the allocation probes must run synchronously on one thread, because GetAllocatedBytesForCurrentThread counts per thread and a continuation resumed on the thread pool would not be counted. Making those tests async would silently break the measurement. Instead the blocking wait moved into an AllocationProbe overload taking Func<Task>, so it lives in a helper rather than a test method. The behaviour is unchanged and the reason is documented where the wait happens. Every handler these tests use completes synchronously, so the task is already finished and nothing actually blocks. One xUnit1031 site was a genuine finding rather than a false positive: CreateStream_Throws_When_No_Handler_Is_Registered drove the enumerator by hand and blocked on MoveNextAsync. It is now an async test using Assert.ThrowsAsync around an await foreach, which is both correct and clearer. Node.js 20 deprecation. actions/checkout and actions/setup-dotnet bumped from v4 to v5 across all three workflows, since the runners are already forcing v4 onto Node 24.
…root Three pieces of tidying that were listed as out of scope on the pull request. Benchmark MediatR versions. Routya.SourceGen.Benchmark pinned 12.4.1 while the other two pinned 12.5.0, so the three suites were not comparable with each other and nothing made that visible. The version now comes from a single MediatRVersion property in Directory.Build.props, so they cannot drift apart again. Held at 12.5.0, the last Apache 2.0 release; moving to 13 or later is a licensing decision rather than a maintenance one. Source generator benchmark baseline. Requests and notifications lived in one class with a single Baseline = true on the request benchmark, so every notification row was divided by a request timing. The Ratio column was arithmetic rather than a comparison: 0.56 meant "a notification takes 56% of the time a request does", not "56% of MediatR". Split into RequestBenchmarks and NotificationBenchmarks, each with its own baseline. Measured correctly, source generated notifications sit at ratio 0.65 against MediatR rather than the meaningless 0.40 reported before. Repository root. Twelve internal planning and analysis documents moved into docs/, leaving README, CHANGELOG, CONTRIBUTING and SECURITY at the top level. Added docs/README.md as an index that separates current guidance from historical notes and states plainly that the performance figures in the latter are superseded. RELEASE_NOTES_V3.md now carries a banner saying the same, since it still contains the "46% faster than MediatR" claim measured on hardware no longer in use and against MediatR 12.4.1. The document is kept as a record rather than edited, because rewriting history is worse than labelling it. Also fixed while in there: the getting started guide, which the README links to, had a Release Notes link pointing outside the repository entirely, and still told people to install version 3.0.0.
Every published timing claim is removed and both benchmark tables are replaced with allocation tables. The reasoning is the same one that drove the allocation budget tests. Allocated bytes are deterministic: they do not depend on CPU, machine load or GC mode, so a figure measured here holds on the reader's hardware, and every one of them is asserted on each build. Timings are not reproducible. On this machine the MediatR baseline, running unchanged code, drifted by a third between two runs, which is larger than any advantage the tables were claiming. Publishing a number a reader cannot reproduce costs more trust than the number ever earned. Removed: - The request table's Mean and Ratio columns, and "9-10% faster than MediatR" - The notification table's Mean and Ratio columns, "30% faster", "7% faster" and "9% faster" - "30% faster for notifications (110ns vs 158ns)" from the lifetimes section - "High-performance dispatching, competitive with MediatR", which was vague enough to imply a speed claim without making a measurable one Both tables now show allocated bytes against MediatR, with the comparison stated per configuration, and explain why timings are absent and where to run the benchmarks if you want them for your own hardware. The notification table also now shows parallel publishing with Scoped handlers at 824 B against 392 B sequential, which was previously unlisted. That is the cost of a scope per handler, and it is what makes a DbContext safe in concurrent handlers, so it is worth stating rather than hiding. One reference to speed is deliberately kept: the Native AOT section still says AOT runs roughly three to four times slower than the JIT. That is a caveat rather than a claim, and removing it would let readers infer AOT is a throughput win when the measurements show the opposite.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
🚀 Pull Request: Routya.Core
📋 Summary
An audit of the dispatch paths turned up six defects: three broke the consumer's build outright, and
two silently corrupted scope handling at runtime. This fixes all of them, adds the regression tests
that would have caught them, replaces the
IRequest<IAsyncEnumerable<T>>streaming workaround with afirst class
IStreamRequest<T>API, and verifiesRoutya.Coreunder trimming and Native AOT.Released as 4.0.0. Test coverage goes from 30 tests in one suite to 118 across three.
🔗 Related Issues (If Any)
Closes #___
Linked to: #___
🛠️ Changes Made
✅ Checklist
Two breaking changes are introduced. Both are recorded under Upgrade notes in
CHANGELOG.md,in a new Breaking Changes section in
README.md, and under Risk below.🧪 Manual Testing Notes
Every fix has a test that was confirmed failing against the previous code before the fix landed, so
none of them pass vacuously. Three things were additionally verified by hand:
binary. It covers an async handler, a synchronous handler returning
decimal, pipeline behavioursand a two handler notification. ILCompiler emitted no IL warnings and the 2.4 MB binary ran
correctly. Note for anyone reproducing: this needs the Desktop Development with C++ workload, and
ILCompiler also needs
vswhere.exeonPATHor the link step fails.previously saw the same disposed dependency on every dispatch; it now sees a fresh one each time.
Microsoft.Bcl.AsyncInterfacesreference appears only in the
.NETStandard2.0dependency group, so no other target frameworkgains a dependency.
Automated, all run and passing:
Routya.TestRoutya.SourceGenerators.TestRoutya.SourceGen.TestAll six demo projects and all four benchmark projects build.
Routya.Corebuilds across all fivetarget frameworks with
CS8632the only warning code, all of which predate this branch, and no ILtrimming or AOT warnings.
💬 Additional Context
29 commits, 67 files, +5096 / -746. Grouped by intent for review:
Runtime dispatch correctness
staticdictionary, so aScopedbehaviourwas built from the first dispatch scope and reused after that scope had been disposed. Any
behaviour holding a
DbContextoperated on a disposed scope from the second dispatch onwards. Thecache being static also leaked instances between DI containers in one process.
IServiceCollectionwere resolved from the rootprovider before the dispatch scope existed. Under scope validation, the ASP.NET Core Development
default, the first publish threw and the notification never reached its handlers.
PublishParallelAsyncgave every handler the same scoped dependency while running themconcurrently, producing
A second operation was started on this contextwith EF Core. Each handlernow gets its own scope. Sequential publishing deliberately still shares one, and both behaviours
are pinned by tests.
Assembly.GetTypes()threwReflectionTypeLoadExceptionand failed application startup if onetype in a scanned assembly could not load.
Source generator
IRequest<string[]>produced anInvalidCastException,surfaced as
CS8785, after which the generator contributed nothing and the build failed withunrelated looking
CS0234errors.feature folder convention.
SendAsynccallingHandleAsyncfor synchronous handlers, which do not have that method.internalandrecordhandlers, so they were never registered.New: first class streaming
IStreamRequest<T>,IStreamRequestHandler<,>,IStreamPipelineBehavior<,>andIRoutya.CreateStream, plus a generatedCreateStreammember. The oldIRequest<IAsyncEnumerable<T>>shape returned a task that handed back a sequence, so the pipelinecompleted before a single item was produced, and a behaviour could not observe items or catch a mid
stream failure. The dispatch scope now lives for the whole enumeration, so a handler can hold a
DbContextacross the stream. Demonstrated in both Web API demos.New: trimming and Native AOT
Zero IL analyzer warnings, enforced on every build through
IsAotCompatible. Removed fiveunreferenced internal types that held the last
Expression.Compilecalls in the library.AddRoutyais split so the scanning overload carries
RequiresUnreferencedCodewithout warning callers whonever scan.
Testing and CI
Routya.SourceGenerators.Test, a Roslyn harness that drives the generator against source textin memory. Without it, a generator defect that breaks the consuming build cannot be expressed as a
failing test.
build.ymlno longer runs BenchmarkDotNet. It passed--job short, but the entry point ignoredcommand line arguments, so CI had been running full benchmark jobs and publishing numbers
nobody read. The same workflow ran only one test suite, behind a
Test-Pathguard that would havesilently skipped it; it now runs all three.
Documentation accuracy
Several published claims were wrong before this release rather than made wrong by it:
Routya.SourceGenerators 3.0.0andRoutya.Core 2.0.0.hardware. It appeared in three places, two of which ship inside the NuGet packages. All now
carry allocation figures instead, which are deterministic and asserted on every build.
Sendrows,240 B on
SendAsyncrows and 32 B on every notification row.IntelliSense.
Risk and rollout
Two breaking changes, both documented:
IRoutyagainedCreateStream. Breaks hand written test doubles implementing the interface.Consumers and runtime mocking frameworks are unaffected.
DefaultRoutya's public constructor gained anIRoutyaStreamDispatcherparameter. Only affectscode calling
new DefaultRoutya(...)directly.Three behavioural changes that are correct but different:
Scopedpipeline behaviours are now built per dispatch, costing about 272 B per dispatch for twobehaviours. Register them
Singletonwhere stateless.300 B per additional handler.
internalhandlers previously skipped by the generator are now registered. If one was registeredmanually as a workaround, that becomes a duplicate registration.
New dependency:
Microsoft.Bcl.AsyncInterfaceson thenetstandard2.0target only, verified inthe packed nuspec.
Publishing:
dotnet.ymltriggers on av*tag, so merging alone publishes nothing. Tagv4.0.0when you are ready to push to NuGet.