Skip to content

v1.4.0-rc.1 — Two-tier dispatch fast paths, notification observation port, DSOFT009–DSOFT012

Pre-release
Pre-release

Choose a tag to compare

@yandersr yandersr released this 16 Sep 17:19
· 20 commits to main since this release

v1.4.0-rc.1 — Two-tier dispatch fast paths, notification observation port, DSOFT009–DSOFT012

Release candidate. The companion packages ship as -rc.1 alongside it.

⚠️ Four behavior changes — read before upgrading

  1. Pipeline components registered through MediatorBuilder default to Scoped, not Transient. Affects AddOpenBehavior, AddStreamBehavior, AddRequestPreProcessor, AddRequestPostProcessor and AddRequestExceptionHandler; AddDispatchObserver keeps its Singleton default. One Transient component made the request's whole chain Transient, so every dispatch re-resolved and re-linked it — the slow path, for a caller who expressed no opinion. A component holding per-dispatch state — a Stopwatch started before the call and read after it — must now be registered Transient explicitly. Passing a lifetime yourself is unaffected; only the unstated default moved.
  2. A Transient handler now makes its pipeline chain Transient. The chain consumes the handler, so the handler's lifetime has to constrain it. For an application whose handler is genuinely Transient, its chain is now rebuilt per dispatch — which is what that registration asked for.
  3. ParallelNotificationPublisher now really runs handlers in parallel. It used to invoke each handler inline on the calling thread, so handlers that complete synchronously — the style INotificationHandler recommends — never overlapped at all. Handlers under this publisher must now be safe to run alongside each other, where synchronous ones were previously serialized by accident. Costs one thread-pool work item per handler. The default dispatch path is unaffected and pays neither.
  4. IRequestExceptionHandler now covers pre-processors. A throw from an IRequestPreProcessor — a validation or authorization check, exactly what an exception handler exists to translate — was never shown to one. A pre-processor exception that escaped to the caller before may now be suppressed by a handler that returns a response. Post-processors remain deliberately outside the guard.

Added

  • ADR-0065 — two-tier Send fast path. A concrete dispatch cache per (request, response) pair, plus an optional AGGRESSIVE armed holder that returns a Singleton handler directly when the pair provably has no pipeline. One-shot, stands down when the pair stops being eligible, and guarded by a process-wide poison latch.
  • ADR-0066 — Publish fast path. The same shape for notifications, with an unrolled dispatch body for groups of up to eight handlers.
  • Specialized behavior chains. The generator predicts the exact ordered chain per composition root and emits a purpose-built nest of typed links, verified positionally against the resolved instances and falling back to the interface-typed adapter when the prediction does not match. Marginal cost per behavior link: 2.09 ns → 0.47 ns.
  • ADR-0007 — notification observation port. IMediatorNotificationObserver lets a tracing bridge or profiler watch a publish without replacing INotificationPublisher — which was the only option before and was never neutral: it disarmed the generated dispatch, resolved handlers from the container instead of the compile-time table, and handed back a different instance for a stateful singleton. An application that registers no observer pays nothing.
  • Four new diagnostics. DSOFT009 — handler skipped because generated code cannot name it (a private nested handler used to produce hundreds of CS0122 errors in files you cannot edit). DSOFT010 — pipeline component registered after the scan that decides whether a chain is built. DSOFT011 — behavior that stays on the runtime path, which is what breaks under Native AOT. DSOFT012 — cached response type with no serializer, caught at build time instead of on the first cached request.
  • Orphaned-component detection in ValidateMediatorHandlers() — reports components that are registered but can never run, and Transient components captured by a chain whose lifetime was already fixed. The only check that sees this across methods, files and assemblies.
  • Per-request-pair registration for the companions. AddMediatorHybridCache<TRequest, TResponse>() and AddMediatorFluentValidation<TRequest, TResponse>() close the behavior over one pair instead of registering an open generic that joins every request's pipeline.
  • XML documentation in the packages. No project set GenerateDocumentationFile, so installing from NuGet showed nothing in IntelliSense.
  • Benchmarks multi-targeted net10.0/net11.0, with runtime-async on net11, a behavior-count scaling suite, and compare-runs.ps1, which compares two runs by overhead above each suite's own baseline so machine drift cancels out.

Changed

  • Generated source no longer depends on the build machine's locale. Every ordering site sorted type names through Comparer<string>.Default, which is culture-sensitive: under da-DK, "Aa" collates as "Å" and sorts after "Z", so the same commit produced a different dispatch table — and different IL — than on an English machine, defeating deterministic-build verification. All 25 sites now use StringComparer.Ordinal.
  • Send(object) stays inlinable at any type count. Inlined, the generated switch grew ~1.2 KB of machine code per request type and stopped fitting the JIT's inlining budget past nine types — 5.5 ns became 9.5 ns on one added type and kept climbing to 20 ns by eighty. Each case body now lives in its own method and the cost stays flat.
  • One chain-resolution protocol instead of seven copies — including the stream path, which was missing the check entirely and pinned the first instance of a Transient stream chain.

Fixed

  • A behavior with a narrowed generic constraint no longer breaks the consumer's build, and the constraint check itself was wrong in three ways: it accepted user-defined implicit conversions (CS0315), crashed the generator on a nested type like Holder<int>.Marker (CS8785), and never checked notnull (CS8714).
  • A default(EquatableArray<T>) took the whole generator down — its backing array is null until assigned, and the incremental pipeline compares default instances.
  • Diagnostics point at the declaration, not at line one of the file.
  • A file-local behavior no longer reaches the generated registry.

Measured

On .NET 11, against a realistic pipeline (Validation → Logging → Metrics → async DB write), each library compared to its own direct call:

DSoft Mediator (SG) DispatchR MediatR
Pipeline vs direct call 1.12× 2.46× 2.47× 3.56×
Send() 2.7 ns 9.8 ns 27.1 ns 40.9 ns
Send(), 5 behaviors 6.6 ns 27.2 ns 31.2 ns 143.5 ns
Startup allocation 32 KB 154 KB 651 KB 1,680 KB

Startup time is the trade: this library adds 22.7 ms where Mediator (SG) adds 10.2 and DispatchR 12.1, because PrecompilePipelines() does the work up front. That is what buys the dispatch figures and the 32 KB. On a service it amortises within seconds; on a short-lived process that never gets there, it does not.

Native AOT is verified by publishing a native binary and running it — typed Send, Send(object), Publish, streams and behaviors, including value-type responses. Verified on win-x64.

Package Versions

Package Version
DSoftStudio.Mediator 1.4.0-rc.1
DSoftStudio.Mediator.Abstractions 1.4.0-rc.1
DSoftStudio.Mediator.FluentValidation 1.0.10-rc.1
DSoftStudio.Mediator.HybridCache 1.0.10-rc.1
DSoftStudio.Mediator.OpenTelemetry 1.1.1-rc.1

Full Changelog: v1.3.0...v1.4.0-rc.1