From 7395e3fae79ad0ccf8e7a5785987fe673d0769eb Mon Sep 17 00:00:00 2001 From: Yander Santiesteban Rojas Date: Thu, 25 Jun 2026 02:43:31 -0300 Subject: [PATCH 1/5] feat(core): dispatch-observer seam (Ports & Adapters) + chain-lifetime captive fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dispatch observer (Ports & Adapters): - Abstractions: IMediatorDispatchObserver port — one observation scope per dispatch wrapping the WHOLE pipeline (pre/post-processors included), with no Activity dependency in the core. - OpenTelemetry: MediatorDispatchTracingObserver adapter replaces MediatorTracingBehavior (deleted) — opens the span once per dispatch. Zero overhead for non-OTel apps: the observer is null on the hot path (a single field-null check in Handle). - MediatorBuilder.AddDispatchObserver; the generator forces a chain when an observer is registered so handler-only requests are still observed. Chain-lifetime captive fix: - PrecompilePipelines now folds the HANDLER's lifetime into the chain lifetime, not only the pipeline components. A Singleton chain wrapping a Transient/Scoped handler captured it (and its scoped deps, e.g. an injected IMediator) for the application lifetime -> "Cannot consume scoped service from singleton" at BuildServiceProvider. The chain is now Scoped when the handler is non-singleton, and still Singleton (zero-alloc, pre-linked once) when everything it wraps is singleton. - Observer-forced chains no longer pin allSingleton=false; the folded lifetimes decide, so a singleton handler keeps a cached Singleton chain. Docs: ADR-0001 + dispatch-pipeline.md (chain lifetime = lowest of handler + components). --- .../adr/0001-architecture-overview.md | 5 +- .../architecture/dispatch-pipeline.md | 2 +- .../IMediatorDispatchObserver.cs | 71 ++++++ .../MediatorPipelineGenerator.cs | 25 +++ .../MediatorDispatchTracingObserver.cs | 92 ++++++++ .../MediatorStreamTracingBehavior.cs | 3 +- .../MediatorTracingBehavior.cs | 72 ------ .../ServiceCollectionExtensions.cs | 6 +- src/DSoftStudio.Mediator/MediatorBuilder.cs | 35 +++ .../PipelineChainHandler.cs | 74 ++++++- ...DispatchTracingObserverIntegrationTests.cs | 156 +++++++++++++ .../DispatchTracingObserverTests.cs | 200 +++++++++++++++++ .../FilteringTests.cs | 32 ++- .../RegistrationTests.cs | 15 +- .../TracingBehaviorTests.cs | 200 ----------------- .../MediatorPipelineGeneratorTests.cs | 15 ++ .../MediatorBuilderIntegrationTests.cs | 67 ++++++ .../Pipelines/DispatchObserverTests.cs | 207 ++++++++++++++++++ 18 files changed, 973 insertions(+), 304 deletions(-) create mode 100644 src/DSoftStudio.Mediator.Abstractions/IMediatorDispatchObserver.cs create mode 100644 src/DSoftStudio.Mediator.OpenTelemetry/MediatorDispatchTracingObserver.cs delete mode 100644 src/DSoftStudio.Mediator.OpenTelemetry/MediatorTracingBehavior.cs create mode 100644 tests/DSoftStudio.Mediator.OpenTelemetry.Tests/DispatchTracingObserverIntegrationTests.cs create mode 100644 tests/DSoftStudio.Mediator.OpenTelemetry.Tests/DispatchTracingObserverTests.cs delete mode 100644 tests/DSoftStudio.Mediator.OpenTelemetry.Tests/TracingBehaviorTests.cs create mode 100644 tests/DSoftStudio.Mediator.Tests/Pipelines/DispatchObserverTests.cs diff --git a/docs/mediator/adr/0001-architecture-overview.md b/docs/mediator/adr/0001-architecture-overview.md index 2e21350..eef2eb8 100644 --- a/docs/mediator/adr/0001-architecture-overview.md +++ b/docs/mediator/adr/0001-architecture-overview.md @@ -231,9 +231,9 @@ Handlers with DI dependencies are registered as Transient. ## 9. Pipeline Lifetime Determination ### Decision -`PrecompilePipelines()` determines each `PipelineChainHandler` lifetime based on the registered components: +`PrecompilePipelines()` determines each `PipelineChainHandler` lifetime from the lifetime of **everything the chain wraps — the handler AND the registered components** (behaviors, pre/post processors, exception handlers): -| Components | Chain Lifetime | +| Lowest lifetime among handler + components | Chain Lifetime | |------------|---------------| | All Singleton | Singleton | | Any Scoped | Scoped | @@ -242,6 +242,7 @@ Handlers with DI dependencies are registered as Transient. ### Rationale - Ensures correct DI semantics without manual configuration. - Singleton chains are cached per-thread for maximum performance. +- The **handler** is included because the chain's constructor consumes it. A Singleton chain wrapping a Transient/Scoped handler would capture that handler — and its scoped dependencies (e.g. an injected `IMediator` used to publish domain events) — for the whole application lifetime, producing the "Cannot consume scoped service from singleton" captive-dependency error at `BuildServiceProvider`. Likewise, instrumentation behaviors (e.g. the profiler's `EventSourceProfilingBehavior`) are registered **Scoped, not Singleton**, so adding them never promotes a chain to Singleton and captures a non-singleton handler. ### Consequences - Registrations added after `PrecompilePipelines()` are not picked up. diff --git a/docs/mediator/architecture/dispatch-pipeline.md b/docs/mediator/architecture/dispatch-pipeline.md index bdcee7e..8a12d0f 100644 --- a/docs/mediator/architecture/dispatch-pipeline.md +++ b/docs/mediator/architecture/dispatch-pipeline.md @@ -52,7 +52,7 @@ Service resolution goes directly through `IServiceProvider` — the standard DI ## What Is Registered at Startup - **`RegisterMediatorHandlers()`** — registers handlers with automatic lifetime selection: **Singleton** for stateless handlers (no constructor parameters), **Transient** for handlers with DI dependencies. This eliminates per-call allocation for stateless handlers while preserving correct DI semantics for handlers that inject services. -- **`PrecompilePipelines()`** — registers `PipelineChainHandler` for every request type that has pipeline components (behaviors, pre/post processors, exception handlers). The chain's lifetime is determined by its components: Singleton when all are Singleton, Scoped when any is Scoped, Transient when any is Transient. Also freezes `RequestObjectDispatch` — the `FrozenDictionary` used by `Send(object)` for runtime-typed dispatch. +- **`PrecompilePipelines()`** — registers `PipelineChainHandler` for every request type that has pipeline components (behaviors, pre/post processors, exception handlers). The chain's lifetime is the lowest lifetime of everything it wraps — the handler **and** its components: Singleton only when the handler and every component are Singleton, Scoped when any is Scoped, Transient when any is Transient. The handler is included because the chain's constructor consumes it: a Singleton chain wrapping a Transient/Scoped handler would capture that handler (and its scoped dependencies, e.g. an injected `IMediator`) for the application lifetime — the classic captive-dependency error. Also freezes `RequestObjectDispatch` — the `FrozenDictionary` used by `Send(object)` for runtime-typed dispatch. - **`PrecompileNotifications()`** — populates `NotificationDispatch.Handlers` static arrays with factory delegates for each notification type. - **`PrecompileStreams()`** — populates `StreamDispatch.Handler` static factory delegates for each stream type. diff --git a/src/DSoftStudio.Mediator.Abstractions/IMediatorDispatchObserver.cs b/src/DSoftStudio.Mediator.Abstractions/IMediatorDispatchObserver.cs new file mode 100644 index 0000000..e25ebdc --- /dev/null +++ b/src/DSoftStudio.Mediator.Abstractions/IMediatorDispatchObserver.cs @@ -0,0 +1,71 @@ +// Copyright (c) DSoftStudio. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using System; + +namespace DSoftStudio.Mediator.Abstractions; + +/// +/// Optional observation port for the request-dispatch boundary (Ports & Adapters). +/// +/// The mediator itself does NOT observe or trace — it merely EXPOSES the dispatch lifecycle so an external +/// adapter (e.g. the OpenTelemetry bridge) can wrap the WHOLE pipeline: pre-processors, behaviors, handler +/// and post-processors. A pipeline behavior cannot do this, because pre-/post-processors run OUTSIDE the +/// behavior chain — so the only place a span can nest every component is the dispatch boundary the mediator +/// owns. This keeps the core tracing-agnostic (no System.Diagnostics.Activity dependency): the core +/// defines the port; the bridge is the adapter. +/// +/// +/// Cost contract: when no observer is registered (the common case) the mediator pays nothing — the dispatch +/// stays on its fast path. When one IS registered, the mediator first reads (cheap, +/// allocation-free) and only calls when something is +/// actually observing — so a registered-but-idle adapter (bridge present, no exporter attached) adds no +/// hot-path cost beyond a single property read. +/// +/// +public interface IMediatorDispatchObserver +{ + /// + /// Cheap, allocation-free check for whether anything is observing right now (e.g. an active tracing + /// listener/exporter). The mediator skips wrapping the dispatch entirely when this is . + /// + bool IsActive { get; } + + /// + /// Called at the START of a request dispatch, BEFORE pre-processors run. The returned scope is disposed + /// when the ENTIRE dispatch completes (after post-processors), so the adapter can open a span that nests + /// every pipeline component. Returns to observe nothing for this dispatch (e.g. the + /// adapter filtered this request type out, or sampling dropped it). + /// + /// + /// The request being dispatched — lets the adapter enrich the observation with request-specific data + /// (e.g. custom span tags) without the mediator knowing what enrichment means. + /// + /// + /// The terminal handler for this dispatch — lets the adapter record the concrete handler type without the + /// mediator resolving anything (the adapter inspects it, e.g. via ). + /// + IMediatorDispatchScope? BeginDispatch(TRequest request, IRequestHandler handler) + where TRequest : IRequest; +} + +/// +/// The lifetime scope of a single observed dispatch, returned by +/// . +/// +/// is called when the dispatch completes (success OR failure), ending the +/// observation. The mediator reports an unhandled failure via BEFORE disposing, so the +/// adapter can mark the observation (e.g. set the span status to error and record the exception). The notion +/// of "the dispatch failed with this exception" is generic dispatch-outcome data — not a tracing concept — +/// so reporting it keeps the core tracing-agnostic. +/// +/// +public interface IMediatorDispatchScope : IDisposable +{ + /// + /// Reports that the dispatch failed with an exception that propagated past every pipeline component + /// (including exception handlers). Called at most once, just before . + /// Not called when the dispatch completes successfully. + /// + void OnError(Exception exception); +} diff --git a/src/DSoftStudio.Mediator.Generators/MediatorPipelineGenerator.cs b/src/DSoftStudio.Mediator.Generators/MediatorPipelineGenerator.cs index 6b2fbe2..0572cb0 100644 --- a/src/DSoftStudio.Mediator.Generators/MediatorPipelineGenerator.cs +++ b/src/DSoftStudio.Mediator.Generators/MediatorPipelineGenerator.cs @@ -290,6 +290,7 @@ private static string GenerateRegistryCode( sb.AppendLine(" bool needsChain = false;"); sb.AppendLine(" bool allSingleton = true;"); sb.AppendLine(" bool hasTransientPipelineComponent = false;"); + sb.AppendLine(" bool hasDispatchObserver = false;"); sb.AppendLine(" foreach (var descriptor in services)"); sb.AppendLine(" {"); sb.AppendLine(" var st = descriptor.ServiceType;"); @@ -309,6 +310,30 @@ private static string GenerateRegistryCode( sb.AppendLine(" if (descriptor.Lifetime == global::Microsoft.Extensions.DependencyInjection.ServiceLifetime.Transient)"); sb.AppendLine(" hasTransientPipelineComponent = true;"); sb.AppendLine(" }"); + sb.AppendLine(" else if (st == typeof(global::DSoftStudio.Mediator.Abstractions.IMediatorDispatchObserver))"); + sb.AppendLine(" {"); + sb.AppendLine(" hasDispatchObserver = true;"); + sb.AppendLine(" }"); + sb.AppendLine(" else if (st == typeof(global::DSoftStudio.Mediator.Abstractions.IRequestHandler))"); + sb.AppendLine(" {"); + sb.AppendLine(" // The chain's ctor consumes the handler, so a non-singleton handler (for example one"); + sb.AppendLine(" // that injects a scoped IMediator) constrains the chain DOWN to Scoped, preventing a"); + sb.AppendLine(" // singleton chain from capturing it (and its scoped deps) for the whole app lifetime."); + sb.AppendLine(" // It does NOT set needsChain: a handler on its own never needs a chain."); + sb.AppendLine(" if (descriptor.Lifetime != global::Microsoft.Extensions.DependencyInjection.ServiceLifetime.Singleton)"); + sb.AppendLine(" allSingleton = false;"); + sb.AppendLine(" }"); + sb.AppendLine(" }"); + sb.AppendLine(); + sb.AppendLine(" // A dispatch observer wraps EVERY request at the dispatch boundary (it lives inside the"); + sb.AppendLine(" // PipelineChainHandler), even handler-only requests with no behaviors/processors. Force a"); + sb.AppendLine(" // chain so such requests are still observed. The lifetime is NOT pinned here: the loop above"); + sb.AppendLine(" // already folded the handler's (and every component's) lifetime into allSingleton, so a"); + sb.AppendLine(" // singleton handler keeps a cached Singleton chain (no per-request resolution) while a"); + sb.AppendLine(" // scoped/transient handler yields a Scoped/Transient chain."); + sb.AppendLine(" if (hasDispatchObserver && !needsChain)"); + sb.AppendLine(" {"); + sb.AppendLine(" needsChain = true;"); sb.AppendLine(" }"); sb.AppendLine(); sb.AppendLine(" if (needsChain)"); diff --git a/src/DSoftStudio.Mediator.OpenTelemetry/MediatorDispatchTracingObserver.cs b/src/DSoftStudio.Mediator.OpenTelemetry/MediatorDispatchTracingObserver.cs new file mode 100644 index 0000000..6063050 --- /dev/null +++ b/src/DSoftStudio.Mediator.OpenTelemetry/MediatorDispatchTracingObserver.cs @@ -0,0 +1,92 @@ +// Copyright (c) DSoftStudio. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using System; +using System.Diagnostics; +using DSoftStudio.Mediator.Abstractions; + +namespace DSoftStudio.Mediator.OpenTelemetry; + +/// +/// OpenTelemetry adapter for the core's port. Opens ONE span per +/// request dispatch that wraps the ENTIRE pipeline — pre-processors, behaviors, handler and post-processors — +/// so every component nests under it. The old MediatorTracingBehavior could not do this: as a pipeline +/// behavior it only saw the behavior chain, while pre-/post-processors run outside it. The mediator stays +/// tracing-agnostic; this is the only place is touched on the request path. +/// +internal sealed class MediatorDispatchTracingObserver(MediatorInstrumentationOptions options) : IMediatorDispatchObserver +{ + private static readonly ActivitySource Source = MediatorInstrumentation.ActivitySource; + private readonly MediatorInstrumentationOptions _options = options; + + /// + /// True only when tracing is enabled AND a listener is attached to our source. + /// is a per-source null/count check (~1 ns) — a listener for ANOTHER source leaves ours untouched — so a + /// registered-but-unexported bridge keeps the mediator on its fast path. + /// + public bool IsActive => _options.EnableTracing && Source.HasListeners(); + + public IMediatorDispatchScope? BeginDispatch(TRequest request, IRequestHandler handler) + where TRequest : IRequest + { + // IsActive already gated EnableTracing + HasListeners; only the per-request type filter remains. + if (_options.Filter is not null && !_options.Filter(typeof(TRequest))) + return null; + + var activity = Source.StartActivity( + MediatorTelemetryMetadata.SpanName, + ActivityKind.Internal); + + if (activity is null) + return null; // sampled out + + if (activity.IsAllDataRequested) + { + activity.SetTag("mediator.request.type", MediatorTelemetryMetadata.RequestType); + activity.SetTag("mediator.response.type", MediatorTelemetryMetadata.ResponseType); + activity.SetTag("mediator.request.kind", MediatorTelemetryMetadata.RequestKind); + // ADR-0049 — the concrete handler behind this request, so an imported trace maps the request span to + // its handler source and renders HTTP/DB child spans as dependencies UNDER it. The pipeline already + // resolved the right handler and handed it to us; we read its type, never resolve anything. + activity.SetTag("mediator.handler.type", ResolveHandlerType(handler).FullName); + + _options.EnrichActivity?.Invoke(activity, request!); + } + + return new DispatchSpanScope(activity, _options); + } + + /// + /// The concrete handler type for this dispatch. The mediator hands us the terminal handler directly; a + /// chain adapter (when present) exposes the real handler via , + /// otherwise the runtime type of the handler IS the concrete type. Typed as so this + /// stays a single non-generic helper (the resolution needs no TRequest/TResponse). + /// + private static Type ResolveHandlerType(object handler) + => handler is IPipelineHandlerTypeAccessor accessor ? accessor.HandlerType : handler.GetType(); + + /// + /// Wraps the dispatch span so the mediator can report the outcome without knowing it is a span: success + /// (Dispose) sets ; an unhandled failure () sets + /// the error status and records the exception per OTel semantic conventions. + /// + private sealed class DispatchSpanScope(Activity activity, MediatorInstrumentationOptions options) : IMediatorDispatchScope + { + private bool _errored; + + public void OnError(Exception exception) + { + _errored = true; + activity.SetStatus(ActivityStatusCode.Error, exception.Message); + activity.SetTag("error.type", exception.GetType().FullName); + ActivityHelper.RecordException(activity, exception, options.RecordExceptionStackTraces); + } + + public void Dispose() + { + if (!_errored) + activity.SetStatus(ActivityStatusCode.Ok); + activity.Dispose(); + } + } +} diff --git a/src/DSoftStudio.Mediator.OpenTelemetry/MediatorStreamTracingBehavior.cs b/src/DSoftStudio.Mediator.OpenTelemetry/MediatorStreamTracingBehavior.cs index 3d4285a..1111813 100644 --- a/src/DSoftStudio.Mediator.OpenTelemetry/MediatorStreamTracingBehavior.cs +++ b/src/DSoftStudio.Mediator.OpenTelemetry/MediatorStreamTracingBehavior.cs @@ -45,7 +45,8 @@ private async IAsyncEnumerable Instrumented( activity.SetTag("mediator.response.type", MediatorStreamMetadata.ResponseType); activity.SetTag("mediator.request.kind", MediatorStreamMetadata.RequestKind); // ADR-0049 — the concrete stream handler behind this request (resolved through the chain, never - // instantiated), so an imported trace maps the stream span to its handler source. See MediatorTracingBehavior. + // instantiated), so an imported trace maps the stream span to its handler source. The request path + // does the same in MediatorDispatchTracingObserver (streams have no dispatch port, so this stays a behavior). activity.SetTag("mediator.handler.type", ResolveHandlerType(next).FullName); options.EnrichActivity?.Invoke(activity, request); diff --git a/src/DSoftStudio.Mediator.OpenTelemetry/MediatorTracingBehavior.cs b/src/DSoftStudio.Mediator.OpenTelemetry/MediatorTracingBehavior.cs deleted file mode 100644 index 8a82f89..0000000 --- a/src/DSoftStudio.Mediator.OpenTelemetry/MediatorTracingBehavior.cs +++ /dev/null @@ -1,72 +0,0 @@ -// Copyright (c) DSoftStudio. All rights reserved. -// Licensed under the MIT License. See LICENSE in the project root for license information. - -using System.Diagnostics; -using DSoftStudio.Mediator.Abstractions; - -namespace DSoftStudio.Mediator.OpenTelemetry; - -/// -/// Pipeline behavior that creates distributed tracing spans for mediator requests. -/// Registers as the outermost behavior to capture the full pipeline duration. -/// -public sealed class MediatorTracingBehavior(MediatorInstrumentationOptions options) : IPipelineBehavior - where TRequest : IRequest -{ - private static readonly ActivitySource Source = MediatorInstrumentation.ActivitySource; - - public async ValueTask Handle( - TRequest request, - IRequestHandler next, - CancellationToken cancellationToken) - { - if (!options.EnableTracing || !Source.HasListeners()) - return await next.Handle(request, cancellationToken); - - if (options.Filter is not null && !options.Filter(typeof(TRequest))) - return await next.Handle(request, cancellationToken); - - using var activity = Source.StartActivity( - MediatorTelemetryMetadata.SpanName, - ActivityKind.Internal); - - if (activity is { IsAllDataRequested: true }) - { - activity.SetTag("mediator.request.type", MediatorTelemetryMetadata.RequestType); - activity.SetTag("mediator.response.type", MediatorTelemetryMetadata.ResponseType); - activity.SetTag("mediator.request.kind", MediatorTelemetryMetadata.RequestKind); - // ADR-0049 — the concrete handler behind this request, so an imported trace maps the request span to - // its handler source and renders HTTP/DB child spans as dependencies UNDER it. The handler is open to - // the behavior only through the chain it was handed as `next` (it is open-generic / shared); the - // pipeline already resolved the right one, exposed via IPipelineHandlerTypeAccessor — we never resolve it. - activity.SetTag("mediator.handler.type", ResolveHandlerType(next).FullName); - - options.EnrichActivity?.Invoke(activity, request); - } - - try - { - var response = await next.Handle(request, cancellationToken); - activity?.SetStatus(ActivityStatusCode.Ok); - return response; - } - catch (Exception ex) - { - if (activity is not null) - { - activity.SetStatus(ActivityStatusCode.Error, ex.Message); - activity.SetTag("error.type", ex.GetType().FullName); - ActivityHelper.RecordException(activity, ex, options.RecordExceptionStackTraces); - } - throw; - } - } - - /// - /// The concrete handler type at the end of the pipeline chain. As the outermost behavior, - /// is a chain adapter that exposes the terminal handler via ; when this - /// behavior is the innermost link, IS the concrete handler, so its runtime type is used. - /// - private static Type ResolveHandlerType(IRequestHandler next) - => next is IPipelineHandlerTypeAccessor accessor ? accessor.HandlerType : next.GetType(); -} diff --git a/src/DSoftStudio.Mediator.OpenTelemetry/ServiceCollectionExtensions.cs b/src/DSoftStudio.Mediator.OpenTelemetry/ServiceCollectionExtensions.cs index 35cb1fa..55e9c97 100644 --- a/src/DSoftStudio.Mediator.OpenTelemetry/ServiceCollectionExtensions.cs +++ b/src/DSoftStudio.Mediator.OpenTelemetry/ServiceCollectionExtensions.cs @@ -29,7 +29,11 @@ public static IServiceCollection AddMediatorInstrumentation( if (options.EnableTracing) { - services.AddTransient(typeof(IPipelineBehavior<,>), typeof(MediatorTracingBehavior<,>)); + // The request span is opened at the dispatch boundary through the core's observation port, so it + // wraps the WHOLE pipeline — pre-/post-processors included (a behavior cannot: they run outside the + // behavior chain). One stateless singleton adapter; the core injects it as IEnumerable and pays + // nothing when it is absent. Streams have no dispatch port, so the stream span stays a behavior. + services.AddSingleton(new MediatorDispatchTracingObserver(options)); services.AddTransient(typeof(IStreamPipelineBehavior<,>), typeof(MediatorStreamTracingBehavior<,>)); } diff --git a/src/DSoftStudio.Mediator/MediatorBuilder.cs b/src/DSoftStudio.Mediator/MediatorBuilder.cs index a01a8c2..2a1a025 100644 --- a/src/DSoftStudio.Mediator/MediatorBuilder.cs +++ b/src/DSoftStudio.Mediator/MediatorBuilder.cs @@ -130,6 +130,41 @@ public MediatorBuilder AddOpenBehavior( => RegisterByOpenInterface(typeof(T), typeof(IRequestExceptionHandler<,>), lifetime, nameof(T), "IRequestExceptionHandler"); + /// + /// Registers a dispatch observer () — an adapter that wraps the + /// WHOLE request-dispatch boundary (pre-processors, behaviors, handler and post-processors), e.g. to open + /// one tracing span around the entire pipeline. Unlike a pipeline behavior, an observer can nest the + /// pre-/post-processors (which run outside the behavior chain) under its scope. + /// + /// The mediator pays nothing when no observer is registered: the dispatch stays on its fast path. Defaults + /// to — an observer is a stateless cross-cutting adapter. + /// + /// + /// The concrete observer type implementing . + /// The DI service lifetime. Defaults to . + /// This builder for chaining. + public MediatorBuilder AddDispatchObserver<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] T>( + ServiceLifetime lifetime = ServiceLifetime.Singleton) + where T : class, IMediatorDispatchObserver + { + Services.Add(new ServiceDescriptor(typeof(IMediatorDispatchObserver), typeof(T), lifetime)); + return this; + } + + /// + /// Registers a pre-configured dispatch observer instance (). Use + /// this overload when the observer carries configuration that cannot be resolved from DI (the OpenTelemetry + /// bridge registers its tracing observer this way). See . + /// + /// The observer instance to register as a singleton. + /// This builder for chaining. + public MediatorBuilder AddDispatchObserver(IMediatorDispatchObserver observer) + { + ArgumentNullException.ThrowIfNull(observer); + Services.AddSingleton(observer); + return this; + } + /// /// Replaces the default sequential notification publisher with a parallel implementation /// that invokes all notification handlers concurrently via . diff --git a/src/DSoftStudio.Mediator/PipelineChainHandler.cs b/src/DSoftStudio.Mediator/PipelineChainHandler.cs index f6fe4e0..e510309 100644 --- a/src/DSoftStudio.Mediator/PipelineChainHandler.cs +++ b/src/DSoftStudio.Mediator/PipelineChainHandler.cs @@ -36,14 +36,26 @@ public sealed class PipelineChainHandler private readonly IRequestExceptionHandler[] _exceptionHandlers; private readonly byte _pipelineMode; // 0=PassThrough, 1=BehaviorsOnly, 2=Full private readonly IRequestHandler _prelinkedChain; + // Optional dispatch-observation port (Ports & Adapters). Null when no adapter is registered (the + // common case) → the hot path never touches it. See IMediatorDispatchObserver. + private readonly IMediatorDispatchObserver? _observer; public PipelineChainHandler( IEnumerable> behaviors, IRequestHandler handler, IEnumerable> preProcessors, IEnumerable> postProcessors, - IEnumerable> exceptionHandlers) + IEnumerable> exceptionHandlers, + // Resolved by DI to an EMPTY sequence when no adapter is registered (non-OTel apps) — so the + // mediator carries no tracing dependency and _observer stays null. + IEnumerable observers) { + // First registered observer wins (one tracing adapter in practice). foreach+break avoids a LINQ + // allocation; constructed once per scope, not on the hot path. + IMediatorDispatchObserver? firstObserver = null; + foreach (var obs in observers) { firstObserver = obs; break; } + _observer = firstObserver; + _behaviors = behaviors is IPipelineBehavior[] bArray ? bArray : [.. behaviors]; @@ -92,6 +104,37 @@ private static byte ComputePipelineMode( /// [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] public ValueTask Handle(TRequest request, CancellationToken cancellationToken) + { + // HOT path: the only cost the dispatch port adds to a non-OTel app is this single field-null check. + // `_observer` is null → straight to HandleCore, whose switch the JIT inlines right here (both this + // method and HandleCore are AggressiveInlining), so the dispatch stays as tight as the pre-observer + // version. The `IsActive` interface call lives in the COLD HandleWithObserver, never in this method. + return (_observer is null) ? HandleCore(request, cancellationToken) : HandleWithObserver(request, cancellationToken); + } + + /// + /// Cold path taken only when a dispatch observer is registered. Splits idle (registered but nothing + /// listening → run the dispatch unobserved) from active (wrap the dispatch in an observation scope). + /// + /// keeps the IsActive interface call and its branches + /// OUT of , so the hot path stays a single null check that inlines cleanly into the + /// cached dispatch. (The measured difference vs. inlining IsActive into Handle is within benchmark + /// noise; keeping it out is simply the cheaper-to-reason-about, robust-across-JITs default.) + /// + /// + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] + private ValueTask HandleWithObserver(TRequest request, CancellationToken cancellationToken) + => _observer!.IsActive + ? HandleObserved(request, cancellationToken) + : HandleCore(request, cancellationToken); + + /// + /// The single 3-way dispatch switch, shared by the hot path ( delegates here) and + /// the cold observer paths. lets the JIT inline the + /// switch into , so the delegation costs nothing on the non-observed fast path. + /// + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] + private ValueTask HandleCore(TRequest request, CancellationToken cancellationToken) => _pipelineMode switch { 0 => _handler.Handle(request, cancellationToken), @@ -99,6 +142,35 @@ public ValueTask Handle(TRequest request, CancellationToken cancellat _ => HandleFull(request, cancellationToken), }; + /// + /// Opens the dispatch-observation scope (e.g. an OpenTelemetry span) around the ENTIRE pipeline so + /// pre-/post-processors — which run outside the behavior chain — nest under it and attribute to THIS + /// dispatch (concurrency-safe per-dispatch identity). Taken only when an adapter is active, so it + /// never touches the non-observed hot path. + /// + private async ValueTask HandleObserved(TRequest request, CancellationToken cancellationToken) + { + // scope may be null when the adapter declined this dispatch (filtered / sampled out) — the + // null-conditional calls below then no-op, so the dispatch runs exactly like the fast path. + // (IsActive was already checked in HandleWithObserver before we got here.) + var scope = _observer!.BeginDispatch(request, _handler); + try + { + return await HandleCore(request, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + // Only exceptions that propagated past EVERY component (incl. exception handlers) reach here — + // i.e. the dispatch genuinely failed. `throw;` preserves the original stack. + scope?.OnError(ex); + throw; + } + finally + { + scope?.Dispose(); + } + } + /// /// Hot path for behaviors-only (no processors, no exception handlers). /// Calls the pre-linked chain directly — no array access, no index, no mutable state. diff --git a/tests/DSoftStudio.Mediator.OpenTelemetry.Tests/DispatchTracingObserverIntegrationTests.cs b/tests/DSoftStudio.Mediator.OpenTelemetry.Tests/DispatchTracingObserverIntegrationTests.cs new file mode 100644 index 0000000..e813043 --- /dev/null +++ b/tests/DSoftStudio.Mediator.OpenTelemetry.Tests/DispatchTracingObserverIntegrationTests.cs @@ -0,0 +1,156 @@ +// Copyright (c) DSoftStudio. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using System.Diagnostics; +using DSoftStudio.Mediator; +using DSoftStudio.Mediator.Abstractions; +using DSoftStudio.Mediator.OpenTelemetry.Tests.Fixtures; +using Microsoft.Extensions.DependencyInjection; + +namespace DSoftStudio.Mediator.OpenTelemetry.Tests; + +// ── Recording processors (manually registered; the generator only registers handlers) ── + +internal sealed class RecordingPreProcessor(List log) : IRequestPreProcessor +{ + public ValueTask Process(TRequest request, CancellationToken ct) + { + log.Add("pre"); + return ValueTask.CompletedTask; + } +} + +internal sealed class RecordingPostProcessor(List log) : IRequestPostProcessor +{ + public ValueTask Process(TRequest request, TResponse response, CancellationToken ct) + { + log.Add("post"); + return ValueTask.CompletedTask; + } +} + +// A request type used ONLY by the tracing-only gap test, with NO pipeline components anywhere — so its +// process-global RequestDispatch flag reflects solely that test's registration (no behavior/metrics forcing +// the chain). The generator only builds a pipeline chain for a type when it sees a behavior/processor for it; +// a dispatch observer must ALSO force the chain, otherwise a handler-only request would never be traced. +public sealed record TracingOnlyPing(string Value) : ICommand; + +public sealed class TracingOnlyPingHandler : IRequestHandler +{ + public ValueTask Handle(TracingOnlyPing request, CancellationToken cancellationToken) + => new($"traced:{request.Value}"); +} + +/// +/// End-to-end proof that a REAL dispatch — wired through AddMediatorInstrumentation and run by the real +/// mediator — opens the request span via the core's dispatch-observation port (so the bridge's observer is +/// picked up by the mediator's IEnumerable<IMediatorDispatchObserver> injection) and that the span +/// wraps the whole pipeline, including pre-/post-processors. The isolated pieces are covered by the observer +/// unit tests and the core wiring tests; this proves they compose on the live Send path. +/// +[Collection("OTel")] +public class DispatchTracingObserverIntegrationTests +{ + [Fact] + public async Task Tracing_only_observes_a_handler_only_request_with_no_other_pipeline_components() + { + // Reset the process-global dispatch flags for this type so the test reflects ONLY this collection's + // registration. Otherwise another test's metrics-on PrecompilePipelines (which closes the open-generic + // metrics behavior for every type) would have already marked this type's chain — masking the gap. The + // [Collection("OTel")] attribute serializes these tests, so nothing re-marks it between here and Send. + ResetDispatchFlags(); + + // Tracing only (NO metrics → no open-generic behavior forcing the chain) + a request with no + // behaviors/processors. The observer must still wrap it, or handler-only requests escape tracing. + var services = new ServiceCollection(); + services.AddMediator().RegisterMediatorHandlers(); + services.AddMediatorInstrumentation(o => o.EnableMetrics = false); + services.PrecompilePipelines(); + + // After the reset, this type has NO scanned pipeline component (no behaviors/pre/post/exception — the + // only open-generic present is the stream-tracing behavior, which the request chain does not scan), so + // the dispatch flag is true SOLELY because a dispatch observer is registered. Without the generator + // forcing a chain for observed handler-only types, this stays false and the request bypasses the chain + // — and the observer — entirely. + services.Any(s => s.ServiceType == typeof(IPipelineBehavior)) + .ShouldBeFalse("metrics are off → no open-generic behavior is closed for this type"); + global::DSoftStudio.Mediator.RequestDispatch.HasPipelineChain + .ShouldBeTrue("the dispatch observer must force a pipeline chain so the handler-only request is observed"); + + using var collector = new ActivityCollector(); + using var provider = services.BuildServiceProvider(); + + var result = await provider.GetRequiredService() + .Send(new TracingOnlyPing("solo"), TestContext.Current.CancellationToken); + + result.ShouldBe("traced:solo"); + var activity = collector.Activities.ShouldHaveSingleItem(); + activity.DisplayName.ShouldBe("TracingOnlyPing command"); + } + + // Test-only: clears the per-type process-global dispatch flags (no public reset exists — they are + // write-once at startup) so a single test can observe its own registration in isolation. + private static void ResetDispatchFlags() + where TRequest : IRequest + { + var type = typeof(global::DSoftStudio.Mediator.RequestDispatch); + foreach (var name in new[] { "_hasPipelineChain", "_isPipelineChainCacheable" }) + { + type.GetField(name, System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static)! + .SetValue(null, false); + } + } + + [Fact] + public async Task Live_send_opens_a_span_that_wraps_pre_and_post_processors() + { + var log = new List(); + var services = new ServiceCollection(); + services.AddMediator().RegisterMediatorHandlers(); + services.AddSingleton(log); + services.AddSingleton>(_ => new RecordingPreProcessor(log)); + services.AddSingleton>(_ => new RecordingPostProcessor(log)); + services.AddMediatorInstrumentation(); + services.PrecompilePipelines(); + + using var collector = new ActivityCollector(); + using var provider = services.BuildServiceProvider(); + + var result = await provider.GetRequiredService() + .Send(new TestCommand("e2e"), TestContext.Current.CancellationToken); + + result.ShouldBe("handled:e2e"); + + var activity = collector.Activities.ShouldHaveSingleItem(); + activity.DisplayName.ShouldBe("TestCommand command"); + activity.Kind.ShouldBe(ActivityKind.Internal); + activity.Status.ShouldBe(ActivityStatusCode.Ok); + activity.GetTagItem("mediator.handler.type")!.ShouldBe(typeof(TestCommandHandler).FullName); + + // The pre- and post-processors ran as part of the same dispatch the span wraps (the exact begin→pre→ + // handler→post→dispose ordering is asserted with a fake observer in the core suite). + log.ShouldBe(new[] { "pre", "post" }); + } + + [Fact] + public async Task Live_send_records_error_status_when_the_handler_throws() + { + var services = new ServiceCollection(); + services.AddMediator().RegisterMediatorHandlers(); + services.AddMediatorInstrumentation(); + services.PrecompilePipelines(); + + using var collector = new ActivityCollector(); + using var provider = services.BuildServiceProvider(); + var mediator = provider.GetRequiredService(); + + await Should.ThrowAsync(async () => + await mediator.Send(new FailingCommand("boom"), TestContext.Current.CancellationToken)); + + var activity = collector.Activities.ShouldHaveSingleItem(); + activity.Status.ShouldBe(ActivityStatusCode.Error); + activity.StatusDescription.ShouldBe("boom"); + activity.GetTagItem("error.type")!.ShouldBe(typeof(InvalidOperationException).FullName); + activity.Events.ShouldContain(e => e.Name == "exception"); + } +} diff --git a/tests/DSoftStudio.Mediator.OpenTelemetry.Tests/DispatchTracingObserverTests.cs b/tests/DSoftStudio.Mediator.OpenTelemetry.Tests/DispatchTracingObserverTests.cs new file mode 100644 index 0000000..f16b34e --- /dev/null +++ b/tests/DSoftStudio.Mediator.OpenTelemetry.Tests/DispatchTracingObserverTests.cs @@ -0,0 +1,200 @@ +// Copyright (c) DSoftStudio. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using System.Diagnostics; +using DSoftStudio.Mediator.OpenTelemetry.Tests.Fixtures; + +namespace DSoftStudio.Mediator.OpenTelemetry.Tests; + +/// +/// Unit tests for — the adapter that opens ONE span per request +/// dispatch through the core's IMediatorDispatchObserver port. It replaces the old per-behavior +/// MediatorTracingBehavior; a behavior could only wrap the behavior chain, while this wraps the WHOLE pipeline +/// (pre-/post-processors included). That "the span nests every pipeline component" guarantee is proven in the +/// core suite (the mediator owns pre/post execution); here we verify the span content and lifecycle. +/// +[Collection("OTel")] +public class DispatchTracingObserverTests +{ + [Fact] + public void Command_creates_span_with_correct_name_and_kind() + { + using var collector = new ActivityCollector(); + var observer = new MediatorDispatchTracingObserver(new MediatorInstrumentationOptions()); + + var scope = observer.BeginDispatch(new TestCommand("test"), new TestCommandHandler()); + scope.ShouldNotBeNull(); + scope!.Dispose(); // success → Ok + + var activity = collector.Activities.ShouldHaveSingleItem(); + activity.DisplayName.ShouldBe("TestCommand command"); + activity.Kind.ShouldBe(ActivityKind.Internal); + activity.Status.ShouldBe(ActivityStatusCode.Ok); + } + + [Fact] + public void Query_creates_span_with_query_kind() + { + using var collector = new ActivityCollector(); + var observer = new MediatorDispatchTracingObserver(new MediatorInstrumentationOptions()); + + var scope = observer.BeginDispatch(new TestQuery(42), new TestQueryHandler()); + scope!.Dispose(); + + var activity = collector.Activities.ShouldHaveSingleItem(); + activity.DisplayName.ShouldBe("TestQuery query"); + activity.GetTagItem("mediator.request.kind")!.ShouldBe("query"); + } + + [Fact] + public void Generic_request_creates_span_with_request_kind() + { + using var collector = new ActivityCollector(); + var observer = new MediatorDispatchTracingObserver(new MediatorInstrumentationOptions()); + + var scope = observer.BeginDispatch(new TestRequest("hello"), new TestRequestHandler()); + scope!.Dispose(); + + var activity = collector.Activities.ShouldHaveSingleItem(); + activity.DisplayName.ShouldBe("TestRequest request"); + activity.GetTagItem("mediator.request.kind")!.ShouldBe("request"); + } + + [Fact] + public void Span_has_correct_tags() + { + using var collector = new ActivityCollector(); + var observer = new MediatorDispatchTracingObserver(new MediatorInstrumentationOptions()); + + var scope = observer.BeginDispatch(new TestCommand("test"), new TestCommandHandler()); + scope!.Dispose(); + + var activity = collector.Activities.ShouldHaveSingleItem(); + activity.GetTagItem("mediator.request.type")!.ShouldBe(typeof(TestCommand).FullName); + activity.GetTagItem("mediator.response.type")!.ShouldBe(typeof(string).FullName); + activity.GetTagItem("mediator.request.kind")!.ShouldBe("command"); + } + + [Fact] + public void Span_tags_concrete_handler_type() + { + // ADR-0049 — the request span must carry the concrete handler type so an imported trace can map it to + // its handler source (and anchor HTTP/DB child spans as dependencies under it). The mediator hands the + // observer the terminal handler directly, so its runtime type IS the concrete type. + using var collector = new ActivityCollector(); + var observer = new MediatorDispatchTracingObserver(new MediatorInstrumentationOptions()); + + var scope = observer.BeginDispatch(new TestCommand("test"), new TestCommandHandler()); + scope!.Dispose(); + + var activity = collector.Activities.ShouldHaveSingleItem(); + activity.GetTagItem("mediator.handler.type")!.ShouldBe(typeof(TestCommandHandler).FullName); + // NOTE: the chain case (the handler is a multi-link adapter that resolves the terminal handler via + // IPipelineHandlerTypeAccessor) is proven in the core suite — HandlerTypeAccessorTests — because the real + // BehaviorHandlerAdapter is internal to DSoftStudio.Mediator. + } + + [Fact] + public void Error_sets_error_status_and_records_exception_event() + { + using var collector = new ActivityCollector(); + var observer = new MediatorDispatchTracingObserver(new MediatorInstrumentationOptions()); + + var scope = observer.BeginDispatch(new FailingCommand("boom"), new FailingCommandHandler()); + scope!.OnError(new InvalidOperationException("boom")); + scope.Dispose(); + + var activity = collector.Activities.ShouldHaveSingleItem(); + activity.Status.ShouldBe(ActivityStatusCode.Error); + activity.StatusDescription.ShouldBe("boom"); + activity.GetTagItem("error.type")!.ShouldBe(typeof(InvalidOperationException).FullName); + + var exceptionEvent = activity.Events.ShouldHaveSingleItem(); + exceptionEvent.Name.ShouldBe("exception"); + } + + [Fact] + public void Exception_event_includes_stacktrace_when_enabled() + { + using var collector = new ActivityCollector(); + var observer = new MediatorDispatchTracingObserver( + new MediatorInstrumentationOptions { RecordExceptionStackTraces = true }); + + var scope = observer.BeginDispatch(new FailingCommand("boom"), new FailingCommandHandler()); + scope!.OnError(new InvalidOperationException("boom")); + scope.Dispose(); + + var exceptionEvent = collector.Activities.Single().Events.ShouldHaveSingleItem(); + var stacktrace = exceptionEvent.Tags.FirstOrDefault(t => t.Key == "exception.stacktrace").Value; + stacktrace.ShouldNotBeNull(); + ((string)stacktrace!).ShouldContain("InvalidOperationException"); + } + + [Fact] + public void Exception_event_excludes_stacktrace_when_disabled() + { + using var collector = new ActivityCollector(); + var observer = new MediatorDispatchTracingObserver( + new MediatorInstrumentationOptions { RecordExceptionStackTraces = false }); + + var scope = observer.BeginDispatch(new FailingCommand("boom"), new FailingCommandHandler()); + scope!.OnError(new InvalidOperationException("boom")); + scope.Dispose(); + + var exceptionEvent = collector.Activities.Single().Events.ShouldHaveSingleItem(); + var stacktrace = exceptionEvent.Tags.FirstOrDefault(t => t.Key == "exception.stacktrace").Value; + stacktrace.ShouldBeNull(); + } + + [Fact] + public void EnrichActivity_callback_adds_custom_tags() + { + using var collector = new ActivityCollector(); + var observer = new MediatorDispatchTracingObserver(new MediatorInstrumentationOptions + { + EnrichActivity = (activity, request) => + { + if (request is TestCommand cmd) + activity.SetTag("custom.value", cmd.Value); + } + }); + + var scope = observer.BeginDispatch(new TestCommand("enriched"), new TestCommandHandler()); + scope!.Dispose(); + + var activity = collector.Activities.ShouldHaveSingleItem(); + activity.GetTagItem("custom.value")!.ShouldBe("enriched"); + } + + [Fact] + public void IsActive_true_when_listener_attached() + { + using var collector = new ActivityCollector(); + var observer = new MediatorDispatchTracingObserver(new MediatorInstrumentationOptions()); + + observer.IsActive.ShouldBeTrue(); + } + + [Fact] + public void Not_active_and_no_span_when_no_listeners() + { + // No ActivityCollector → no listener on our source. The core gates on IsActive (so BeginDispatch is + // never called); even if it were, StartActivity returns null and no span is created. + var observer = new MediatorDispatchTracingObserver(new MediatorInstrumentationOptions()); + + observer.IsActive.ShouldBeFalse(); + observer.BeginDispatch(new TestCommand("test"), new TestCommandHandler()).ShouldBeNull(); + } + + [Fact] + public void Not_active_when_tracing_disabled() + { + // Even with a listener attached, disabling tracing makes the observer inactive, so the core never wraps + // the dispatch. + using var collector = new ActivityCollector(); + var observer = new MediatorDispatchTracingObserver( + new MediatorInstrumentationOptions { EnableTracing = false }); + + observer.IsActive.ShouldBeFalse(); + } +} diff --git a/tests/DSoftStudio.Mediator.OpenTelemetry.Tests/FilteringTests.cs b/tests/DSoftStudio.Mediator.OpenTelemetry.Tests/FilteringTests.cs index 956a88d..c79fd7f 100644 --- a/tests/DSoftStudio.Mediator.OpenTelemetry.Tests/FilteringTests.cs +++ b/tests/DSoftStudio.Mediator.OpenTelemetry.Tests/FilteringTests.cs @@ -45,34 +45,31 @@ public void Dispose() } [Fact] - public async Task Filter_suppresses_tracing_for_matched_request() + public void Filter_suppresses_tracing_for_matched_request() { using var collector = new ActivityCollector(); - var options = new MediatorInstrumentationOptions + var observer = new MediatorDispatchTracingObserver(new MediatorInstrumentationOptions { Filter = type => !type.Name.StartsWith("HealthCheck") - }; - var behavior = new MediatorTracingBehavior(options); - var handler = new HealthCheckHandler(); + }); - var result = await behavior.Handle(new HealthCheckQuery(), handler, TestContext.Current.CancellationToken); + var scope = observer.BeginDispatch(new HealthCheckQuery(), new HealthCheckHandler()); - result.ShouldBe("ok"); + scope.ShouldBeNull(); collector.Activities.ShouldBeEmpty(); } [Fact] - public async Task Filter_allows_tracing_for_non_matched_request() + public void Filter_allows_tracing_for_non_matched_request() { using var collector = new ActivityCollector(); - var options = new MediatorInstrumentationOptions + var observer = new MediatorDispatchTracingObserver(new MediatorInstrumentationOptions { Filter = type => !type.Name.StartsWith("HealthCheck") - }; - var behavior = new MediatorTracingBehavior(options); - var handler = new TestCommandHandler(); + }); - await behavior.Handle(new TestCommand("test"), handler, TestContext.Current.CancellationToken); + var scope = observer.BeginDispatch(new TestCommand("test"), new TestCommandHandler()); + scope!.Dispose(); collector.Activities.ShouldHaveSingleItem(); } @@ -155,14 +152,13 @@ public async Task Filter_suppresses_notification_tracing_and_metrics() } [Fact] - public async Task Null_filter_instruments_everything() + public void Null_filter_instruments_everything() { using var collector = new ActivityCollector(); - var options = new MediatorInstrumentationOptions { Filter = null }; - var behavior = new MediatorTracingBehavior(options); - var handler = new HealthCheckHandler(); + var observer = new MediatorDispatchTracingObserver(new MediatorInstrumentationOptions { Filter = null }); - await behavior.Handle(new HealthCheckQuery(), handler, TestContext.Current.CancellationToken); + var scope = observer.BeginDispatch(new HealthCheckQuery(), new HealthCheckHandler()); + scope!.Dispose(); collector.Activities.ShouldHaveSingleItem(); } diff --git a/tests/DSoftStudio.Mediator.OpenTelemetry.Tests/RegistrationTests.cs b/tests/DSoftStudio.Mediator.OpenTelemetry.Tests/RegistrationTests.cs index 8fe2db2..23960c5 100644 --- a/tests/DSoftStudio.Mediator.OpenTelemetry.Tests/RegistrationTests.cs +++ b/tests/DSoftStudio.Mediator.OpenTelemetry.Tests/RegistrationTests.cs @@ -11,17 +11,17 @@ namespace DSoftStudio.Mediator.OpenTelemetry.Tests; public class RegistrationTests { [Fact] - public void AddMediatorInstrumentation_registers_tracing_behaviors() + public void AddMediatorInstrumentation_registers_dispatch_tracing_observer() { + // The request span is opened through the core's dispatch-observation port (so it wraps pre-/post- + // processors, which a behavior cannot), NOT as a pipeline behavior. var services = new ServiceCollection(); services.AddMediatorInstrumentation(); - var pipelineBehaviors = services - .Where(s => s.ServiceType == typeof(IPipelineBehavior<,>)) - .ToList(); + var observer = services.SingleOrDefault(s => s.ServiceType == typeof(IMediatorDispatchObserver)); - pipelineBehaviors.ShouldContain(s => - s.ImplementationType == typeof(MediatorTracingBehavior<,>)); + observer.ShouldNotBeNull(); + observer!.ImplementationInstance.ShouldBeOfType(); } [Fact] @@ -108,8 +108,7 @@ public void Disabling_tracing_skips_tracing_behaviors() options.EnableTracing = false; }); - services.ShouldNotContain(s => - s.ImplementationType == typeof(MediatorTracingBehavior<,>)); + services.ShouldNotContain(s => s.ServiceType == typeof(IMediatorDispatchObserver)); services.ShouldNotContain(s => s.ImplementationType == typeof(MediatorStreamTracingBehavior<,>)); } diff --git a/tests/DSoftStudio.Mediator.OpenTelemetry.Tests/TracingBehaviorTests.cs b/tests/DSoftStudio.Mediator.OpenTelemetry.Tests/TracingBehaviorTests.cs deleted file mode 100644 index 7c18489..0000000 --- a/tests/DSoftStudio.Mediator.OpenTelemetry.Tests/TracingBehaviorTests.cs +++ /dev/null @@ -1,200 +0,0 @@ -// Copyright (c) DSoftStudio. All rights reserved. -// Licensed under the MIT License. See LICENSE in the project root for license information. - -using System.Diagnostics; -using DSoftStudio.Mediator.Abstractions; -using DSoftStudio.Mediator.OpenTelemetry.Tests.Fixtures; - -namespace DSoftStudio.Mediator.OpenTelemetry.Tests; - -[Collection("OTel")] -public class TracingBehaviorTests -{ - [Fact] - public async Task Command_creates_span_with_correct_name_and_kind() - { - using var collector = new ActivityCollector(); - var options = new MediatorInstrumentationOptions(); - var behavior = new MediatorTracingBehavior(options); - var handler = new TestCommandHandler(); - - var result = await behavior.Handle( - new TestCommand("test"), handler, TestContext.Current.CancellationToken); - - result.ShouldBe("handled:test"); - - var activity = collector.Activities.ShouldHaveSingleItem(); - activity.DisplayName.ShouldBe("TestCommand command"); - activity.Kind.ShouldBe(ActivityKind.Internal); - activity.Status.ShouldBe(ActivityStatusCode.Ok); - } - - [Fact] - public async Task Query_creates_span_with_query_kind() - { - using var collector = new ActivityCollector(); - var options = new MediatorInstrumentationOptions(); - var behavior = new MediatorTracingBehavior(options); - var handler = new TestQueryHandler(); - - await behavior.Handle(new TestQuery(42), handler, TestContext.Current.CancellationToken); - - var activity = collector.Activities.ShouldHaveSingleItem(); - activity.DisplayName.ShouldBe("TestQuery query"); - activity.GetTagItem("mediator.request.kind")!.ShouldBe("query"); - } - - [Fact] - public async Task Generic_request_creates_span_with_request_kind() - { - using var collector = new ActivityCollector(); - var options = new MediatorInstrumentationOptions(); - var behavior = new MediatorTracingBehavior(options); - var handler = new TestRequestHandler(); - - await behavior.Handle(new TestRequest("hello"), handler, TestContext.Current.CancellationToken); - - var activity = collector.Activities.ShouldHaveSingleItem(); - activity.DisplayName.ShouldBe("TestRequest request"); - activity.GetTagItem("mediator.request.kind")!.ShouldBe("request"); - } - - [Fact] - public async Task Span_has_correct_tags() - { - using var collector = new ActivityCollector(); - var options = new MediatorInstrumentationOptions(); - var behavior = new MediatorTracingBehavior(options); - var handler = new TestCommandHandler(); - - await behavior.Handle(new TestCommand("test"), handler, TestContext.Current.CancellationToken); - - var activity = collector.Activities.ShouldHaveSingleItem(); - activity.GetTagItem("mediator.request.type")!.ShouldBe(typeof(TestCommand).FullName); - activity.GetTagItem("mediator.response.type")!.ShouldBe(typeof(string).FullName); - activity.GetTagItem("mediator.request.kind")!.ShouldBe("command"); - } - - [Fact] - public async Task Span_tags_concrete_handler_type() - { - // ADR-0049 — the request span must carry the concrete handler type so an imported trace can map it to - // its handler source (and anchor HTTP/DB child spans as dependencies under it). Here the behavior is the - // innermost link (next IS the handler), so the runtime type is used. - using var collector = new ActivityCollector(); - var options = new MediatorInstrumentationOptions(); - var behavior = new MediatorTracingBehavior(options); - var handler = new TestCommandHandler(); - - await behavior.Handle(new TestCommand("test"), handler, TestContext.Current.CancellationToken); - - var activity = collector.Activities.ShouldHaveSingleItem(); - activity.GetTagItem("mediator.handler.type")!.ShouldBe(typeof(TestCommandHandler).FullName); - // NOTE: the chain case (next is a multi-link adapter that resolves the terminal handler via - // IPipelineHandlerTypeAccessor) is proven in the core suite — HandlerTypeAccessorTests — because the real - // BehaviorHandlerAdapter is internal to DSoftStudio.Mediator and any IRequestHandler stub here would be - // swept up by the mediator's handler source-generator as a duplicate registration. - } - - [Fact] - public async Task Exception_sets_error_status_and_records_exception_event() - { - using var collector = new ActivityCollector(); - var options = new MediatorInstrumentationOptions(); - var behavior = new MediatorTracingBehavior(options); - var handler = new FailingCommandHandler(); - - await Should.ThrowAsync(async () => - await behavior.Handle(new FailingCommand("boom"), handler, TestContext.Current.CancellationToken)); - - var activity = collector.Activities.ShouldHaveSingleItem(); - activity.Status.ShouldBe(ActivityStatusCode.Error); - activity.StatusDescription.ShouldBe("boom"); - activity.GetTagItem("error.type")!.ShouldBe(typeof(InvalidOperationException).FullName); - - var exceptionEvent = activity.Events.ShouldHaveSingleItem(); - exceptionEvent.Name.ShouldBe("exception"); - } - - [Fact] - public async Task Exception_event_includes_stacktrace_when_enabled() - { - using var collector = new ActivityCollector(); - var options = new MediatorInstrumentationOptions { RecordExceptionStackTraces = true }; - var behavior = new MediatorTracingBehavior(options); - var handler = new FailingCommandHandler(); - - await Should.ThrowAsync(async () => - await behavior.Handle(new FailingCommand("boom"), handler, TestContext.Current.CancellationToken)); - - var exceptionEvent = collector.Activities.Single().Events.ShouldHaveSingleItem(); - var stacktrace = exceptionEvent.Tags.FirstOrDefault(t => t.Key == "exception.stacktrace").Value; - stacktrace.ShouldNotBeNull(); - ((string)stacktrace!).ShouldContain("InvalidOperationException"); - } - - [Fact] - public async Task Exception_event_excludes_stacktrace_when_disabled() - { - using var collector = new ActivityCollector(); - var options = new MediatorInstrumentationOptions { RecordExceptionStackTraces = false }; - var behavior = new MediatorTracingBehavior(options); - var handler = new FailingCommandHandler(); - - await Should.ThrowAsync(async () => - await behavior.Handle(new FailingCommand("boom"), handler, TestContext.Current.CancellationToken)); - - var exceptionEvent = collector.Activities.Single().Events.ShouldHaveSingleItem(); - var stacktrace = exceptionEvent.Tags.FirstOrDefault(t => t.Key == "exception.stacktrace").Value; - stacktrace.ShouldBeNull(); - } - - [Fact] - public async Task EnrichActivity_callback_adds_custom_tags() - { - using var collector = new ActivityCollector(); - var options = new MediatorInstrumentationOptions - { - EnrichActivity = (activity, request) => - { - if (request is TestCommand cmd) - activity.SetTag("custom.value", cmd.Value); - } - }; - var behavior = new MediatorTracingBehavior(options); - var handler = new TestCommandHandler(); - - await behavior.Handle(new TestCommand("enriched"), handler, TestContext.Current.CancellationToken); - - var activity = collector.Activities.ShouldHaveSingleItem(); - activity.GetTagItem("custom.value")!.ShouldBe("enriched"); - } - - [Fact] - public async Task No_span_when_no_listeners() - { - // No ActivityCollector → no listeners - var options = new MediatorInstrumentationOptions(); - var behavior = new MediatorTracingBehavior(options); - var handler = new TestCommandHandler(); - - var result = await behavior.Handle( - new TestCommand("test"), handler, TestContext.Current.CancellationToken); - - result.ShouldBe("handled:test"); - // No exception = pass-through works correctly - } - - [Fact] - public async Task No_span_when_tracing_disabled() - { - using var collector = new ActivityCollector(); - var options = new MediatorInstrumentationOptions { EnableTracing = false }; - var behavior = new MediatorTracingBehavior(options); - var handler = new TestCommandHandler(); - - await behavior.Handle(new TestCommand("test"), handler, TestContext.Current.CancellationToken); - - collector.Activities.ShouldBeEmpty(); - } -} diff --git a/tests/DSoftStudio.Mediator.Tests/Generators/MediatorPipelineGeneratorTests.cs b/tests/DSoftStudio.Mediator.Tests/Generators/MediatorPipelineGeneratorTests.cs index 26e8d4c..7b5dd9f 100644 --- a/tests/DSoftStudio.Mediator.Tests/Generators/MediatorPipelineGeneratorTests.cs +++ b/tests/DSoftStudio.Mediator.Tests/Generators/MediatorPipelineGeneratorTests.cs @@ -40,6 +40,21 @@ public void Generates_MediatorRegistry_For_RequestHandler() code.ShouldContain("GetUser"); } + [Fact] + public void RegisterPipeline_FoldsHandlerLifetimeIntoChainLifetime() + { + // ADR-0001: the chain lifetime is the lowest of everything it wraps - including the HANDLER, not just + // the pipeline components. The chain's constructor consumes the handler, so a Singleton chain wrapping + // a Transient/Scoped handler would capture it (and its scoped deps, e.g. an injected IMediator) for the + // whole app lifetime: the captive-dependency crash (cannot consume scoped service from singleton). + // RegisterPipeline must therefore fold the IRequestHandler descriptor's lifetime + // into allSingleton, not only the behaviors/processors. + var (result, _) = GeneratorTestHarness.Run(RequestHandler); + var code = result.AllSource(); + + code.ShouldContain("st == typeof(global::DSoftStudio.Mediator.Abstractions.IRequestHandler)"); + } + [Fact] public void Emits_Aot_Behavior_Closure_For_OpenGeneric_Behavior_And_Processor() { diff --git a/tests/DSoftStudio.Mediator.Tests/Integration/MediatorBuilderIntegrationTests.cs b/tests/DSoftStudio.Mediator.Tests/Integration/MediatorBuilderIntegrationTests.cs index 53cd265..d935f5d 100644 --- a/tests/DSoftStudio.Mediator.Tests/Integration/MediatorBuilderIntegrationTests.cs +++ b/tests/DSoftStudio.Mediator.Tests/Integration/MediatorBuilderIntegrationTests.cs @@ -134,6 +134,32 @@ public async IAsyncEnumerable Handle( } } +public sealed record BuilderObserverPing() : IRequest; + +public sealed class BuilderObserverPingHandler : IRequestHandler +{ + public ValueTask Handle(BuilderObserverPing request, CancellationToken ct) + => new("observed-ok"); +} + +public sealed class BuilderDispatchObserver(List log) : IMediatorDispatchObserver +{ + public bool IsActive => true; + + public IMediatorDispatchScope? BeginDispatch(TRequest request, IRequestHandler handler) + where TRequest : IRequest + { + log.Add("begin"); + return new Scope(log); + } + + private sealed class Scope(List log) : IMediatorDispatchScope + { + public void OnError(Exception exception) => log.Add("error"); + public void Dispose() => log.Add("dispose"); + } +} + public sealed record BuilderExcPing() : IRequest; public sealed class BuilderExcPingHandler : IRequestHandler @@ -230,6 +256,47 @@ public async Task AddMediator_WithPreProcessor_ExecutesBefore() log.ShouldContain("pre"); } + /// + /// AddMediator(configure) + AddDispatchObserver<T> registers a dispatch observer that wraps the whole + /// dispatch — even a handler-only request with no behaviors/processors (the builder + generator force a + /// pipeline chain so the observer is not bypassed). + /// + [Fact] + public async Task AddMediator_WithDispatchObserverType_WrapsTheDispatch() + { + var log = new List(); + var services = new ServiceCollection(); + services.AddSingleton(log); + services.AddMediator(builder => builder.AddDispatchObserver()); + + await using var sp = services.BuildServiceProvider(); + var sender = sp.GetRequiredService(); + + var result = await sender.Send(new BuilderObserverPing(), TestContext.Current.CancellationToken); + + result.ShouldBe("observed-ok"); + log.ShouldBe(new[] { "begin", "dispose" }); + } + + /// + /// AddMediator(configure) + AddDispatchObserver(instance) registers a pre-configured observer instance. + /// + [Fact] + public async Task AddMediator_WithDispatchObserverInstance_WrapsTheDispatch() + { + var log = new List(); + var services = new ServiceCollection(); + services.AddMediator(builder => builder.AddDispatchObserver(new BuilderDispatchObserver(log))); + + await using var sp = services.BuildServiceProvider(); + var sender = sp.GetRequiredService(); + + var result = await sender.Send(new BuilderObserverPing(), TestContext.Current.CancellationToken); + + result.ShouldBe("observed-ok"); + log.ShouldBe(new[] { "begin", "dispose" }); + } + /// /// AddMediator(configure) + AddRequestPostProcessor registers a post-processor /// that executes after the handler. diff --git a/tests/DSoftStudio.Mediator.Tests/Pipelines/DispatchObserverTests.cs b/tests/DSoftStudio.Mediator.Tests/Pipelines/DispatchObserverTests.cs new file mode 100644 index 0000000..e85d8b5 --- /dev/null +++ b/tests/DSoftStudio.Mediator.Tests/Pipelines/DispatchObserverTests.cs @@ -0,0 +1,207 @@ +// Copyright (c) DSoftStudio. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using DSoftStudio.Mediator.Abstractions; +using Microsoft.Extensions.DependencyInjection; + +namespace DSoftStudio.Mediator.Tests.Pipelines; + +// ── Request types + handlers (the generator registers these) ──────── + +public sealed record ObservedPing : IRequest; +public sealed record ObservedThrowPing : IRequest; +// Used ONLY by the handler-only test — never with pipeline components anywhere — so its process-global +// RequestDispatch flag is not polluted by other tests that add pre/post to a shared request type. +public sealed record ObservedSoloPing : IRequest; + +public sealed class ObservedSoloPingHandler(List log) : IRequestHandler +{ + public ValueTask Handle(ObservedSoloPing request, CancellationToken ct) + { + log.Add("handler"); + return new(7); + } +} + +public sealed class ObservedPingHandler(List log) : IRequestHandler +{ + public ValueTask Handle(ObservedPing request, CancellationToken ct) + { + log.Add("handler"); + return new(42); + } +} + +public sealed class ObservedThrowPingHandler(List log) : IRequestHandler +{ + public ValueTask Handle(ObservedThrowPing request, CancellationToken ct) + { + log.Add("handler"); + throw new InvalidOperationException("boom"); + } +} + +// ── Pre/Post processors that log into the shared order log ────────── + +public sealed class LoggingPreProcessor(List log) : IRequestPreProcessor +{ + public ValueTask Process(TRequest request, CancellationToken ct) + { + log.Add("pre"); + return ValueTask.CompletedTask; + } +} + +public sealed class LoggingPostProcessor(List log) : IRequestPostProcessor +{ + public ValueTask Process(TRequest request, TResponse response, CancellationToken ct) + { + log.Add("post"); + return ValueTask.CompletedTask; + } +} + +// ── Fake dispatch observer that records the dispatch lifecycle ────── + +public sealed class RecordingObserver(List log) : IMediatorDispatchObserver +{ + public bool Active { get; set; } = true; + + public bool IsActive => Active; + + public IMediatorDispatchScope? BeginDispatch(TRequest request, IRequestHandler handler) + where TRequest : IRequest + { + log.Add($"observer:begin:{typeof(TRequest).Name}:{handler.GetType().Name}"); + return new RecordingScope(log); + } + + private sealed class RecordingScope(List log) : IMediatorDispatchScope + { + public void OnError(Exception exception) => log.Add($"observer:error:{exception.GetType().Name}"); + public void Dispose() => log.Add("observer:dispose"); + } +} + +// ── Tests ─────────────────────────────────────────────────────────── + +/// +/// Verifies the core's wiring: an active observer's scope wraps the +/// WHOLE pipeline (pre-/post-processors included — the reason a behavior could not do this), an inactive or +/// absent observer leaves the fast path untouched, and an unhandled failure is reported before the scope is +/// disposed. The hot-path 0-overhead of the null-observer case is proven separately by the benchmark suite. +/// +public class DispatchObserverTests +{ + private static ServiceCollection BuildServices(List log) + { + var services = new ServiceCollection(); + services.AddMediator().RegisterMediatorHandlers(); + services.AddSingleton(log); + return services; + } + + [Fact] + public async Task Active_observer_scope_wraps_the_whole_pipeline_including_pre_and_post_processors() + { + var log = new List(); + var services = BuildServices(log); + services.AddSingleton>(_ => new LoggingPreProcessor(log)); + services.AddSingleton>(_ => new LoggingPostProcessor(log)); + services.AddSingleton(new RecordingObserver(log)); + services.PrecompilePipelines(); + + using var provider = services.BuildServiceProvider(); + var result = await provider.GetRequiredService().Send(new ObservedPing(), TestContext.Current.CancellationToken); + + result.ShouldBe(42); + // The scope opens BEFORE the pre-processor and closes AFTER the post-processor — the span nests the + // entire dispatch, which a pipeline behavior (running only inside the behavior chain) cannot achieve. + log.ShouldBe(new[] + { + "observer:begin:ObservedPing:ObservedPingHandler", + "pre", + "handler", + "post", + "observer:dispose", + }); + } + + [Fact] + public async Task Active_observer_wraps_a_handler_only_request_with_no_pipeline_components() + { + // The COMMON case: a request with NO behaviors/pre/post/exception handlers. The observer must still + // wrap it — otherwise handler-only dispatches (the majority) would never be traced. + var log = new List(); + var services = BuildServices(log); + services.AddSingleton(new RecordingObserver(log)); + services.PrecompilePipelines(); + + using var provider = services.BuildServiceProvider(); + var result = await provider.GetRequiredService().Send(new ObservedSoloPing(), TestContext.Current.CancellationToken); + + result.ShouldBe(7); + log.ShouldBe(new[] { "observer:begin:ObservedSoloPing:ObservedSoloPingHandler", "handler", "observer:dispose" }); + } + + [Fact] + public async Task Inactive_observer_is_not_invoked() + { + var log = new List(); + var services = BuildServices(log); + services.AddSingleton>(_ => new LoggingPreProcessor(log)); + services.AddSingleton>(_ => new LoggingPostProcessor(log)); + services.AddSingleton(new RecordingObserver(log) { Active = false }); + services.PrecompilePipelines(); + + using var provider = services.BuildServiceProvider(); + var result = await provider.GetRequiredService().Send(new ObservedPing(), TestContext.Current.CancellationToken); + + result.ShouldBe(42); + // Registered but inactive → the dispatch never enters the observed path; no begin/dispose recorded. + log.ShouldBe(new[] { "pre", "handler", "post" }); + } + + [Fact] + public async Task Unhandled_exception_is_reported_to_the_scope_then_disposed() + { + var log = new List(); + var services = BuildServices(log); + services.AddSingleton>(_ => new LoggingPreProcessor(log)); + services.AddSingleton>(_ => new LoggingPostProcessor(log)); + services.AddSingleton(new RecordingObserver(log)); + services.PrecompilePipelines(); + + using var provider = services.BuildServiceProvider(); + var mediator = provider.GetRequiredService(); + + await Should.ThrowAsync(async () => + await mediator.Send(new ObservedThrowPing(), TestContext.Current.CancellationToken)); + + // OnError fires before Dispose; the post-processor never runs (the dispatch failed). + log.ShouldBe(new[] + { + "observer:begin:ObservedThrowPing:ObservedThrowPingHandler", + "pre", + "handler", + "observer:error:InvalidOperationException", + "observer:dispose", + }); + } + + [Fact] + public async Task No_observer_registered_runs_the_pipeline_normally() + { + var log = new List(); + var services = BuildServices(log); + services.AddSingleton>(_ => new LoggingPreProcessor(log)); + services.AddSingleton>(_ => new LoggingPostProcessor(log)); + services.PrecompilePipelines(); + + using var provider = services.BuildServiceProvider(); + var result = await provider.GetRequiredService().Send(new ObservedPing(), TestContext.Current.CancellationToken); + + result.ShouldBe(42); + log.ShouldBe(new[] { "pre", "handler", "post" }); + } +} From 4a1ffdb0d329f399ba7f3c677bfabb6a02930869 Mon Sep 17 00:00:00 2001 From: Yander Santiesteban Rojas Date: Thu, 25 Jun 2026 10:29:35 -0300 Subject: [PATCH 2/5] feat(di): order-independent auto-detection of handler DI lifetime A handler with constructor dependencies was always registered Transient, so under scope-per-request (one DI scope per HTTP request) it was re-resolved and re-allocated every request - ~24 B + ~6.5 ns each, against the zero-allocation premise. RegisterMediatorHandlers now stages each dependency-carrying request handler (recording the descriptor it created plus the dependency types). The finalization step - PrecompilePipelines or the single-call AddMediator - runs HandlerLifetimeOptimizer.Apply once every registration is present, raising each handler to the longest SAFE lifetime its dependencies allow: - Singleton when every dependency is a singleton (cached, zero-alloc per request, and via the chain-lifetime fold a cached Singleton chain too) - Scoped when any dependency is scoped - Transient (unchanged) when any dependency is transient or unregistered Running at finalization makes it order-independent: a dependency registered AFTER RegisterMediatorHandlers (the common composition-root order) is still seen. A reference-identity guard upgrades a handler only while the generator's own descriptor is still the winning registration, so any user re-registration - including an identical AddTransient that pins Transient - is respected. [HandlerLifetime(...)] pins a lifetime explicitly. No reflection: only registered ServiceDescriptor.Lifetime values are read, so it is AOT/trim-safe and never touches the dispatch hot path (generated dispatch IL is byte-identical). Tests: HandlerLifetimeOptimizerTests (logic, order-independence, reference guard, idempotency) and HandlerLifetimeAutoDetectionTests (end-to-end through the generated path). Full OSS suite green. --- .../HandlerLifetimeAttribute.cs | 48 +++++ .../DependencyInjectionGenerator.cs | 169 +++++++++++++++--- .../MediatorPipelineGenerator.cs | 2 + .../HandlerLifetimeOptimizer.cs | 156 ++++++++++++++++ .../HandlerLifetimeOptimizerTests.cs | 165 +++++++++++++++++ .../Integration/EnterpriseIntegrationTests.cs | 4 + .../HandlerLifetimeAutoDetectionTests.cs | 147 +++++++++++++++ .../Validation/HandlerValidationTests.cs | 4 + 8 files changed, 670 insertions(+), 25 deletions(-) create mode 100644 src/DSoftStudio.Mediator.Abstractions/HandlerLifetimeAttribute.cs create mode 100644 src/DSoftStudio.Mediator/HandlerLifetimeOptimizer.cs create mode 100644 tests/DSoftStudio.Mediator.Tests/HandlerLifetimeOptimizerTests.cs create mode 100644 tests/DSoftStudio.Mediator.Tests/Lifetimes/HandlerLifetimeAutoDetectionTests.cs diff --git a/src/DSoftStudio.Mediator.Abstractions/HandlerLifetimeAttribute.cs b/src/DSoftStudio.Mediator.Abstractions/HandlerLifetimeAttribute.cs new file mode 100644 index 0000000..f51fffb --- /dev/null +++ b/src/DSoftStudio.Mediator.Abstractions/HandlerLifetimeAttribute.cs @@ -0,0 +1,48 @@ +// Copyright (c) DSoftStudio. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using System; + +namespace DSoftStudio.Mediator.Abstractions +{ + /// + /// The dependency-injection lifetime a mediator handler is registered with. Mirrors the three + /// Microsoft.Extensions.DependencyInjection.ServiceLifetime values without coupling the + /// abstractions package to that dependency. + /// + public enum HandlerLifetime + { + /// A new instance per resolution. + Transient, + + /// One instance per DI scope (e.g. per web request). + Scoped, + + /// A single shared instance for the whole application lifetime. + Singleton, + } + + /// + /// Pins the DI lifetime the mediator registers this handler with, overriding the automatic + /// dependency-driven detection. + /// + /// By default the mediator picks the lifetime that matches the handler's constructor dependencies: + /// when every dependency is itself a singleton (cached, + /// zero-allocation per request), when any dependency is scoped + /// (cached per scope), otherwise. Apply this attribute when + /// the handler must use a specific lifetime regardless - for example + /// when it must be a fresh instance per call because it (or + /// a dependency) carries per-call state. + /// + /// + [AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] + public sealed class HandlerLifetimeAttribute : Attribute + { + /// Initializes the attribute with the lifetime to register the handler with. + /// The lifetime to pin. + public HandlerLifetimeAttribute(HandlerLifetime lifetime) => Lifetime = lifetime; + + /// The pinned lifetime. + public HandlerLifetime Lifetime { get; } + } +} diff --git a/src/DSoftStudio.Mediator.Generators/DependencyInjectionGenerator.cs b/src/DSoftStudio.Mediator.Generators/DependencyInjectionGenerator.cs index 139b0e2..1c11c8b 100644 --- a/src/DSoftStudio.Mediator.Generators/DependencyInjectionGenerator.cs +++ b/src/DSoftStudio.Mediator.Generators/DependencyInjectionGenerator.cs @@ -117,7 +117,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) .ThenBy(static h => h.HandlerType) .ToArray(); - // Local self-handlers only — external self-handlers are now discovered + // Local self-handlers only - external self-handlers are now discovered // as regular handlers via [assembly: MediatorHandlerRegistration] attributes. var localSelfHandlers = selfHandlers.IsDefaultOrEmpty ? [] @@ -147,7 +147,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) /// /// Reports compile-time diagnostics for request/stream handler types that have /// multiple implementations. With Microsoft.Extensions.DI, GetRequiredService<T> - /// returns the last registration — earlier handlers are silently ignored. + /// returns the last registration - earlier handlers are silently ignored. /// Notification handlers are excluded (multiple handlers per notification is by design). /// private static void ReportDuplicateHandlers(SourceProductionContext spc, HandlerInfo[] allHandlers) @@ -182,7 +182,7 @@ private static void ReportDuplicateHandlers(SourceProductionContext spc, Handler interfaceType, handlerNames)); } - // Notification handlers: multiple implementations per type is expected — no diagnostic + // Notification handlers: multiple implementations per type is expected - no diagnostic } } @@ -251,10 +251,45 @@ private static void ReportMissingHandlers( if (HandlerDiscovery.IsFileLocal(classDecl)) return null; - // Handlers with no constructor parameters are stateless — safe to register as Singleton. + // Handlers with no constructor parameters are stateless - safe to register as Singleton. bool isStateless = symbol.InstanceConstructors.Length > 0 && symbol.InstanceConstructors.All(static c => c.Parameters.IsEmpty); + // Capture the dependency types of the constructor DI will use (the greediest public ctor) so the + // runtime optimizer can raise this handler's lifetime from its dependency lifetimes (AOT-safe: the + // types are emitted as typeof, never reflected). Empty for stateless handlers. + string depTypes = ""; + if (!isStateless) + { + var ctor = symbol.InstanceConstructors + .Where(static c => c.DeclaredAccessibility == Accessibility.Public) + .OrderByDescending(static c => c.Parameters.Length) + .FirstOrDefault(); + if (ctor is not null && !ctor.Parameters.IsEmpty) + { + var depsBuilder = new System.Text.StringBuilder(); + for (int p = 0; p < ctor.Parameters.Length; p++) + { + if (p > 0) depsBuilder.Append('|'); // '|' never appears in a type name (generics use < , >) + depsBuilder.Append(ctor.Parameters[p].Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)); + } + depTypes = depsBuilder.ToString(); + } + } + + // An explicit [HandlerLifetime(...)] pins the lifetime: it is emitted directly and skips the optimizer. + string? explicitLifetime = null; + foreach (var attr in symbol.GetAttributes()) + { + if (attr.AttributeClass?.ToDisplayString() == "DSoftStudio.Mediator.Abstractions.HandlerLifetimeAttribute" + && attr.ConstructorArguments.Length == 1 + && attr.ConstructorArguments[0].Value is int lifetimeValue) + { + explicitLifetime = lifetimeValue switch { 1 => "Scoped", 2 => "Singleton", _ => "Transient" }; + break; + } + } + foreach (var iface in symbol.AllInterfaces) { var ns = iface.ContainingNamespace.ToDisplayString(); @@ -275,7 +310,7 @@ private static void ReportMissingHandlers( return new HandlerInfo( $"global::DSoftStudio.Mediator.Abstractions.IRequestHandler<{request},{response}>", symbol.ToDisplayString(HandlerDiscovery.NullableFullyQualifiedFormat), - isStateless); + isStateless, depTypes, explicitLifetime); } case "INotificationHandler`1": @@ -286,7 +321,7 @@ private static void ReportMissingHandlers( return new HandlerInfo( $"global::DSoftStudio.Mediator.Abstractions.INotificationHandler<{notification}>", symbol.ToDisplayString(HandlerDiscovery.NullableFullyQualifiedFormat), - isStateless); + isStateless, depTypes, explicitLifetime); } case "IStreamRequestHandler`2": @@ -300,7 +335,7 @@ private static void ReportMissingHandlers( return new HandlerInfo( $"global::DSoftStudio.Mediator.Abstractions.IStreamRequestHandler<{request},{response}>", symbol.ToDisplayString(HandlerDiscovery.NullableFullyQualifiedFormat), - isStateless); + isStateless, depTypes, explicitLifetime); } } } @@ -353,7 +388,7 @@ private static void ReportMissingHandlers( /// Handlers discovered in the current project (emit assembly attributes for these). /// Local + external handlers (register all in DI). /// Self-handling request classes (IRequest<T> + static Execute). - /// The consuming assembly name — used to generate a unique namespace for extension classes. + /// The consuming assembly name - used to generate a unique namespace for extension classes. private static string GenerateCode(HandlerInfo[] localHandlers, HandlerInfo[] allHandlers, SelfHandlerDetail[] selfHandlers, string assemblyName) { var sanitizedAsm = HandlerDiscovery.SanitizeIdentifier(assemblyName); @@ -413,31 +448,103 @@ private static string GenerateCode(HandlerInfo[] localHandlers, HandlerInfo[] al // Register ALL handlers (local + external) in DI var registeredConcreteTypes = new System.Collections.Generic.HashSet(); + // Request handlers eligible for the deferred lifetime upgrade: they resolve by interface, carry + // dependencies, and have no explicit [HandlerLifetime]. Each is added through an explicit descriptor + // captured in a local so the finalization pass can verify ours is still the live registration. + var optimizableHandlers = new System.Collections.Generic.List<(int Index, HandlerInfo Handler)>(); foreach (var handler in allHandlers) { - // Stateless handlers (no constructor parameters) ? Singleton (zero allocation per call). - // Handlers with DI dependencies ? Transient (safe default). - sb.Append(handler.IsStateless - ? " global::Microsoft.Extensions.DependencyInjection.ServiceCollectionServiceExtensions.AddSingleton<" - : " global::Microsoft.Extensions.DependencyInjection.ServiceCollectionServiceExtensions.AddTransient<"); - sb.Append(handler.InterfaceType); - sb.Append(", "); - sb.Append(handler.HandlerType); - sb.AppendLine(">(services);"); + var isOptimizable = handler.ExplicitLifetime is null + && !handler.IsStateless + && handler.DepTypes.Length > 0 + && handler.InterfaceType.Contains("IRequestHandler<"); + + if (isOptimizable) + { + // Same effect as AddTransient, but the captured descriptor reference lets + // HandlerLifetimeOptimizer.Apply confirm ours is still the winning registration before + // upgrading. The startup optimizer may then raise it to Singleton (all-singleton deps) or + // Scoped (any scoped dep) once every registration is visible. + var optIndex = optimizableHandlers.Count; + sb.Append(" var __mh"); + sb.Append(optIndex); + sb.Append(" = global::Microsoft.Extensions.DependencyInjection.ServiceDescriptor.Transient(typeof("); + sb.Append(handler.InterfaceType); + sb.Append("), typeof("); + sb.Append(handler.HandlerType); + sb.AppendLine("));"); + sb.Append(" services.Add(__mh"); + sb.Append(optIndex); + sb.AppendLine(");"); + optimizableHandlers.Add((optIndex, handler)); + } + else + { + // Lifetime: an explicit [HandlerLifetime] wins; otherwise stateless handlers are Singleton + // (zero allocation per call) and handlers-with-deps default to Transient. + var addMethod = handler.ExplicitLifetime switch + { + "Singleton" => "AddSingleton<", + "Scoped" => "AddScoped<", + "Transient" => "AddTransient<", + _ => handler.IsStateless ? "AddSingleton<" : "AddTransient<", + }; + sb.Append(" global::Microsoft.Extensions.DependencyInjection.ServiceCollectionServiceExtensions."); + sb.Append(addMethod); + sb.Append(handler.InterfaceType); + sb.Append(", "); + sb.Append(handler.HandlerType); + sb.AppendLine(">(services);"); + } // Notification and stream dispatch tables resolve by CONCRETE type, - // so we must also register the implementation type directly. + // so we must also register the implementation type directly (matching lifetime). if (!handler.InterfaceType.Contains("IRequestHandler<") && registeredConcreteTypes.Add(handler.HandlerType)) { - sb.Append(handler.IsStateless - ? " global::Microsoft.Extensions.DependencyInjection.Extensions.ServiceCollectionDescriptorExtensions.TryAddSingleton(services, typeof(" - : " global::Microsoft.Extensions.DependencyInjection.Extensions.ServiceCollectionDescriptorExtensions.TryAddTransient(services, typeof("); + var tryAddMethod = handler.ExplicitLifetime switch + { + "Singleton" => "TryAddSingleton", + "Scoped" => "TryAddScoped", + "Transient" => "TryAddTransient", + _ => handler.IsStateless ? "TryAddSingleton" : "TryAddTransient", + }; + sb.Append(" global::Microsoft.Extensions.DependencyInjection.Extensions.ServiceCollectionDescriptorExtensions."); + sb.Append(tryAddMethod); + sb.Append("(services, typeof("); sb.Append(handler.HandlerType); sb.AppendLine("));"); } } + // Deferred lifetime optimization: stage each eligible request handler so the finalization step + // (PrecompilePipelines / the single-call AddMediator) can raise it from the Transient default to the + // longest SAFE lifetime its dependencies allow - once ALL registrations are visible, regardless of + // whether a dependency was registered before or after this call. AOT-safe (dependency types emitted + // as typeof; only registered descriptor lifetimes are read). + if (optimizableHandlers.Count > 0) + { + sb.AppendLine(); + sb.AppendLine(" global::DSoftStudio.Mediator.HandlerLifetimeOptimizer.Stage(services, new (global::Microsoft.Extensions.DependencyInjection.ServiceDescriptor, global::System.Type[])[]"); + sb.AppendLine(" {"); + foreach (var (index, handler) in optimizableHandlers) + { + sb.Append(" (__mh"); + sb.Append(index); + sb.Append(", new global::System.Type[] { "); + var deps = handler.DepTypes.Split('|'); + for (int d = 0; d < deps.Length; d++) + { + if (d > 0) sb.Append(", "); + sb.Append("typeof("); + sb.Append(deps[d]); + sb.Append(')'); + } + sb.AppendLine(" }),"); + } + sb.AppendLine(" });"); + } + // Register local self-handler adapters in DI foreach (var handler in selfHandlers) { @@ -653,7 +760,7 @@ private static void GenerateHandlerValidatorWorker(StringBuilder sb, HandlerInfo foreach (var handler in allHandlers) { // Skip duplicate interface types (e.g. multiple notification handlers - // for the same notification type — GetServices validates all at once). + // for the same notification type - GetServices validates all at once). if (!emittedInterfaces.Add(handler.InterfaceType)) continue; @@ -750,15 +857,26 @@ public override int GetHashCode() } } - internal readonly struct HandlerInfo(string iface, string handler, bool isStateless = false) : IEquatable + internal readonly struct HandlerInfo(string iface, string handler, bool isStateless = false, string depTypes = "", string? explicitLifetime = null) : IEquatable { public string InterfaceType { get; } = iface; public string HandlerType { get; } = handler; public bool IsStateless { get; } = isStateless; + // Comma-joined fully-qualified constructor dependency types (greediest public ctor), captured at + // compile time so the runtime optimizer can pick the lifetime from their registered lifetimes + // without reflection. A string (not an array) keeps the incremental-generator model cached by value. + public string DepTypes { get; } = depTypes; + + // "Singleton"/"Scoped"/"Transient" when the handler carries an explicit [HandlerLifetime]; null otherwise. + public string? ExplicitLifetime { get; } = explicitLifetime; + public bool Equals(HandlerInfo other) => InterfaceType == other.InterfaceType && - HandlerType == other.HandlerType; + HandlerType == other.HandlerType && + IsStateless == other.IsStateless && + DepTypes == other.DepTypes && + ExplicitLifetime == other.ExplicitLifetime; public override bool Equals(object? obj) => obj is HandlerInfo other && Equals(other); @@ -767,7 +885,8 @@ public override int GetHashCode() { unchecked { - return (InterfaceType.GetHashCode() * 397) ^ HandlerType.GetHashCode(); + int hash = (InterfaceType.GetHashCode() * 397) ^ HandlerType.GetHashCode(); + return (hash * 397) ^ DepTypes.GetHashCode(); } } } diff --git a/src/DSoftStudio.Mediator.Generators/MediatorPipelineGenerator.cs b/src/DSoftStudio.Mediator.Generators/MediatorPipelineGenerator.cs index 0572cb0..8dd2d09 100644 --- a/src/DSoftStudio.Mediator.Generators/MediatorPipelineGenerator.cs +++ b/src/DSoftStudio.Mediator.Generators/MediatorPipelineGenerator.cs @@ -416,6 +416,7 @@ private static string GenerateRegistryCode( sb.AppendLine(" {"); + sb.AppendLine(" global::DSoftStudio.Mediator.HandlerLifetimeOptimizer.Apply(services);"); sb.AppendLine(" MediatorRegistry.RegisterPipelineChains(services);"); sb.AppendLine(" global::DSoftStudio.Mediator.RequestObjectDispatch.Freeze();"); sb.AppendLine(" return services;"); @@ -450,6 +451,7 @@ private static string GenerateRegistryCode( sb.AppendLine(" configure(builder);"); // 4. Precompile pipelines (closes open generics, registers chains, freezes dispatch). + sb.AppendLine(" global::DSoftStudio.Mediator.HandlerLifetimeOptimizer.Apply(services);"); sb.AppendLine(" MediatorRegistry.RegisterPipelineChains(services);"); sb.AppendLine(" global::DSoftStudio.Mediator.RequestObjectDispatch.Freeze();"); sb.AppendLine(" return services;"); diff --git a/src/DSoftStudio.Mediator/HandlerLifetimeOptimizer.cs b/src/DSoftStudio.Mediator/HandlerLifetimeOptimizer.cs new file mode 100644 index 0000000..ff31981 --- /dev/null +++ b/src/DSoftStudio.Mediator/HandlerLifetimeOptimizer.cs @@ -0,0 +1,156 @@ +// Copyright (c) DSoftStudio. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using Microsoft.Extensions.DependencyInjection; + +namespace DSoftStudio.Mediator +{ + /// + /// Two-phase, order-independent optimizer that raises an auto-detected request-handler lifetime from + /// the conservative Transient default to the longest safe lifetime allowed by its constructor + /// dependencies: + /// + /// Singleton when every dependency is a singleton - cached, zero-allocation + /// per request (and, via the pipeline-chain lifetime fold, a cached Singleton chain too). + /// Scoped when any dependency is scoped - cached per scope; the handler can + /// only be resolved inside a scope anyway, so this adds no constraint. + /// Transient (unchanged) when any dependency is itself transient or + /// unregistered - it is not safe to capture for longer. + /// + /// + /// Why two phases. The generated RegisterMediatorHandlers() calls to + /// record the descriptors it created for dependency-carrying handlers (by reference) plus their + /// dependency types. The decision itself is DEFERRED to , which the generated + /// finalization step (PrecompilePipelines() / the single-call AddMediator(configure)) runs + /// once every registration is present - so a dependency registered AFTER the handler (the common + /// composition-root order: RegisterMediatorHandlers() first, repositories/DbContext after) + /// is still seen. Running before the pipeline chains are registered lets the chain lifetime fold observe + /// the upgraded value. + /// + /// + /// No reflection - only registered values are read - so it is + /// AOT/trim-safe and never touches the dispatch hot path. A user re-registration of a handler (any + /// lifetime, a different implementation, or even an identical re-Add) replaces the generator's + /// descriptor and is left untouched, because upgrades a handler only when the + /// generator's own descriptor is still the winning (last) registration for its service type. + /// + /// + public static class HandlerLifetimeOptimizer + { + // Build-time staging keyed by the service collection instance. GC-scoped (no leak): the entry dies + // with the collection, and Apply removes it once consumed. Startup-only - never on the hot path. + private static readonly ConditionalWeakTable> Staged = new(); + + private readonly struct StagedHandler + { + public StagedHandler(ServiceDescriptor descriptor, Type[] dependencies) + { + Descriptor = descriptor; + Dependencies = dependencies; + } + + public ServiceDescriptor Descriptor { get; } + + public Type[] Dependencies { get; } + } + + /// + /// Records, during handler registration, the generator's own handler descriptors (held by reference) + /// and their constructor dependency types, to be resolved later by . Called by + /// generated code; safe to call multiple times (entries accumulate per collection). + /// + /// The service collection the handlers were added to. + /// For each optimizable handler: the exact the + /// generator added for it, and the constructor dependency types to inspect. + public static void Stage( + IServiceCollection services, + (ServiceDescriptor Descriptor, Type[] Dependencies)[] handlers) + { + if (services is null) throw new ArgumentNullException(nameof(services)); + if (handlers is null || handlers.Length == 0) return; + + var list = Staged.GetOrCreateValue(services); + foreach (var h in handlers) + { + if (h.Descriptor is null || h.Dependencies is null) continue; + list.Add(new StagedHandler(h.Descriptor, h.Dependencies)); + } + } + + /// + /// Resolves every staged handler's lifetime against the now-complete service collection and upgrades + /// it in place where safe. Called by the generated finalization step before the pipeline chains are + /// registered. Idempotent: consumes the staged entries, and a re-run finds nothing to do. + /// + /// The fully-populated service collection, immediately before building. + public static void Apply(IServiceCollection services) + { + if (services is null) throw new ArgumentNullException(nameof(services)); + if (!Staged.TryGetValue(services, out var staged) || staged.Count == 0) return; + Staged.Remove(services); + + // Index the effective lifetime of every registered service type (last registration wins, + // matching how GetRequiredService resolves). One pass, no reflection. + var lifetimes = new Dictionary(services.Count); + foreach (var d in services) + lifetimes[d.ServiceType] = d.Lifetime; + + foreach (var handler in staged) + { + var target = ComputeLifetime(lifetimes, handler.Dependencies); + if (target == ServiceLifetime.Transient) + continue; // nothing to upgrade + + var descriptor = handler.Descriptor; + + // The last descriptor for the service type is the one DI resolves. Upgrade ours only when it + // is STILL that winner (reference identity) - any user re-registration appends a newer + // descriptor and is therefore respected, including an identical re-Add that forces Transient. + int last = -1; + for (int i = services.Count - 1; i >= 0; i--) + { + if (services[i].ServiceType == descriptor.ServiceType) + { + last = i; + break; + } + } + + if (last < 0 || !ReferenceEquals(services[last], descriptor)) + continue; + + if (descriptor.ImplementationType is null) + continue; // generator always supplies an implementation type; guard defensively + + services[last] = new ServiceDescriptor(descriptor.ServiceType, descriptor.ImplementationType, target); + } + } + + /// + /// The safe lifetime for a handler given its dependency lifetimes. Only called for handlers that + /// HAVE dependencies (stateless handlers are already Singleton): Singleton if all are singletons, + /// Scoped if any is scoped (and none transient), Transient if any is transient or unregistered. + /// + private static ServiceLifetime ComputeLifetime(Dictionary lifetimes, Type[] deps) + { + var result = ServiceLifetime.Singleton; + + foreach (var dep in deps) + { + if (!lifetimes.TryGetValue(dep, out var lt)) + return ServiceLifetime.Transient; // unknown dependency - stay safe + + if (lt == ServiceLifetime.Transient) + return ServiceLifetime.Transient; // a transient dependency keeps the handler transient + + if (lt == ServiceLifetime.Scoped) + result = ServiceLifetime.Scoped; // a scoped dependency caps the handler at Scoped + } + + return result; + } + } +} diff --git a/tests/DSoftStudio.Mediator.Tests/HandlerLifetimeOptimizerTests.cs b/tests/DSoftStudio.Mediator.Tests/HandlerLifetimeOptimizerTests.cs new file mode 100644 index 0000000..c6566b8 --- /dev/null +++ b/tests/DSoftStudio.Mediator.Tests/HandlerLifetimeOptimizerTests.cs @@ -0,0 +1,165 @@ +// Copyright (c) DSoftStudio. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using System; +using System.Linq; +using DSoftStudio.Mediator; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace DSoftStudio.Mediator.Tests; + +/// +/// Unit tests for - the two-phase (Stage at registration, Apply at +/// finalization) pass that raises an auto-detected handler lifetime from the conservative Transient default +/// to the longest SAFE lifetime its dependencies allow. Driven with a synthetic descriptor (no real handler +/// types) so it is fully isolated, and exercises the order-independence and the reference-identity override +/// guard directly. +/// +public class HandlerLifetimeOptimizerTests +{ + private interface IProbe { } + private sealed class ProbeImpl : IProbe { } + private sealed class SingletonDep { } + private sealed class ScopedDep { } + private sealed class TransientDep { } + + // Adds the probe handler the way the generator does (explicit Transient descriptor) and stages it. + private static ServiceDescriptor StageProbe(IServiceCollection services, params Type[] deps) + { + var descriptor = ServiceDescriptor.Transient(typeof(IProbe), typeof(ProbeImpl)); + services.Add(descriptor); + HandlerLifetimeOptimizer.Stage(services, new[] { (descriptor, deps) }); + return descriptor; + } + + private static ServiceLifetime LifetimeOf(IServiceCollection services) => + services.Last(d => d.ServiceType == typeof(IProbe)).Lifetime; + + [Fact] + public void AllSingletonDependencies_UpgradesHandlerToSingleton() + { + var s = new ServiceCollection(); + s.AddSingleton(); + StageProbe(s, typeof(SingletonDep)); + + HandlerLifetimeOptimizer.Apply(s); + + Assert.Equal(ServiceLifetime.Singleton, LifetimeOf(s)); + } + + [Fact] + public void DependencyRegisteredAfterStaging_IsStillSeenAtApply() + { + var s = new ServiceCollection(); + StageProbe(s, typeof(SingletonDep)); // handler staged BEFORE its dependency exists + s.AddSingleton(); // dependency registered AFTER - the common composition order + + HandlerLifetimeOptimizer.Apply(s); + + // Order-independence: Apply sees the dependency because it runs at finalization, not at staging. + Assert.Equal(ServiceLifetime.Singleton, LifetimeOf(s)); + } + + [Fact] + public void AnyScopedDependency_CapsHandlerAtScoped() + { + var s = new ServiceCollection(); + s.AddSingleton(); + s.AddScoped(); + StageProbe(s, typeof(SingletonDep), typeof(ScopedDep)); + + HandlerLifetimeOptimizer.Apply(s); + + Assert.Equal(ServiceLifetime.Scoped, LifetimeOf(s)); + } + + [Fact] + public void AnyTransientDependency_KeepsHandlerTransient() + { + var s = new ServiceCollection(); + s.AddSingleton(); + s.AddTransient(); + StageProbe(s, typeof(SingletonDep), typeof(TransientDep)); + + HandlerLifetimeOptimizer.Apply(s); + + Assert.Equal(ServiceLifetime.Transient, LifetimeOf(s)); + } + + [Fact] + public void UnregisteredDependency_KeepsHandlerTransient() + { + var s = new ServiceCollection(); + StageProbe(s, typeof(SingletonDep)); // SingletonDep deliberately never registered + + HandlerLifetimeOptimizer.Apply(s); + + Assert.Equal(ServiceLifetime.Transient, LifetimeOf(s)); + } + + [Fact] + public void UserReRegistrationWithDifferentLifetime_IsRespected() + { + var s = new ServiceCollection(); + s.AddSingleton(); + StageProbe(s, typeof(SingletonDep)); + s.AddScoped(); // user override AFTER staging - must be left alone + + HandlerLifetimeOptimizer.Apply(s); + + Assert.Equal(ServiceLifetime.Scoped, LifetimeOf(s)); // respected; NOT raised to Singleton + } + + [Fact] + public void IdenticalUserReRegistration_ForcesTransient_ReferenceGuard() + { + var s = new ServiceCollection(); + s.AddSingleton(); + StageProbe(s, typeof(SingletonDep)); + s.AddTransient(); // user re-adds an IDENTICAL Transient to force Transient + + HandlerLifetimeOptimizer.Apply(s); + + // The reference-identity guard distinguishes the user's new descriptor from the generator's, so the + // explicit Transient is preserved - a heuristic that matched on (impl, Transient) would wrongly upgrade. + Assert.Equal(ServiceLifetime.Transient, LifetimeOf(s)); + } + + [Fact] + public void ApplyWithoutStaging_IsNoOp() + { + var s = new ServiceCollection(); + s.AddTransient(); // not staged + + HandlerLifetimeOptimizer.Apply(s); // must not throw or change anything + + Assert.Equal(ServiceLifetime.Transient, LifetimeOf(s)); + } + + [Fact] + public void Apply_IsIdempotent() + { + var s = new ServiceCollection(); + s.AddSingleton(); + StageProbe(s, typeof(SingletonDep)); + + HandlerLifetimeOptimizer.Apply(s); + HandlerLifetimeOptimizer.Apply(s); // second run finds nothing staged + + Assert.Equal(ServiceLifetime.Singleton, LifetimeOf(s)); + } + + [Fact] + public void Upgraded_SingletonHandlerWithSingletonDep_BuildsAndValidates() + { + var s = new ServiceCollection(); + s.AddSingleton(); + StageProbe(s, typeof(SingletonDep)); + HandlerLifetimeOptimizer.Apply(s); + + // Singleton consuming a Singleton is captive-free: BuildServiceProvider with scope validation succeeds. + using var provider = s.BuildServiceProvider(new ServiceProviderOptions { ValidateScopes = true, ValidateOnBuild = true }); + Assert.Same(provider.GetRequiredService(), provider.GetRequiredService()); + } +} diff --git a/tests/DSoftStudio.Mediator.Tests/Integration/EnterpriseIntegrationTests.cs b/tests/DSoftStudio.Mediator.Tests/Integration/EnterpriseIntegrationTests.cs index 337dead..7486b41 100644 --- a/tests/DSoftStudio.Mediator.Tests/Integration/EnterpriseIntegrationTests.cs +++ b/tests/DSoftStudio.Mediator.Tests/Integration/EnterpriseIntegrationTests.cs @@ -479,6 +479,10 @@ public MultiProjectIntegrationTests() services.AddSingleton(new FlakeyState(0)); services.AddSingleton(new ThreadSafeChaosRandom()); + // Dependencies required by the auto-lifetime-detection test handlers (Lifetimes/) + services.AddSingleton(); + services.AddScoped(); + _provider = services.BuildServiceProvider(); _mediator = _provider.GetRequiredService(); } diff --git a/tests/DSoftStudio.Mediator.Tests/Lifetimes/HandlerLifetimeAutoDetectionTests.cs b/tests/DSoftStudio.Mediator.Tests/Lifetimes/HandlerLifetimeAutoDetectionTests.cs new file mode 100644 index 0000000..f38d1b9 --- /dev/null +++ b/tests/DSoftStudio.Mediator.Tests/Lifetimes/HandlerLifetimeAutoDetectionTests.cs @@ -0,0 +1,147 @@ +// Copyright (c) DSoftStudio. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using System.Linq; +using DSoftStudio.Mediator.Abstractions; +using Microsoft.Extensions.DependencyInjection; + +namespace DSoftStudio.Mediator.Tests.Lifetimes; + +// -- Dependencies with known lifetimes (registered BEFORE RegisterMediatorHandlers so the +// generated HandlerLifetimeOptimizer pass can see them) --- + +public sealed class AutoSingletonDep { } +public sealed class AutoScopedDep { } + +// -- Requests + handlers exercising the auto-detection path end-to-end --- + +public sealed record AutoSingletonReq : IRequest; +public sealed class AutoSingletonReqHandler(AutoSingletonDep dep) : IRequestHandler +{ + public Guid InstanceId { get; } = Guid.NewGuid(); + private readonly AutoSingletonDep _dep = dep; + public ValueTask Handle(AutoSingletonReq request, CancellationToken ct) => new(_dep is null ? -1 : 42); +} + +public sealed record AutoScopedReq : IRequest; +public sealed class AutoScopedReqHandler(AutoScopedDep dep) : IRequestHandler +{ + private readonly AutoScopedDep _dep = dep; + public ValueTask Handle(AutoScopedReq request, CancellationToken ct) => new(_dep is null ? -1 : 42); +} + +public sealed record AutoUnknownDepReq : IRequest; +public sealed class AutoUnknownDepReqHandler(AutoSingletonDep dep) : IRequestHandler +{ + private readonly AutoSingletonDep _dep = dep; + public ValueTask Handle(AutoUnknownDepReq request, CancellationToken ct) => new(_dep is null ? -1 : 42); +} + +public sealed record AutoPinnedTransientReq : IRequest; +[HandlerLifetime(HandlerLifetime.Transient)] +public sealed class AutoPinnedTransientReqHandler(AutoSingletonDep dep) : IRequestHandler +{ + private readonly AutoSingletonDep _dep = dep; + public ValueTask Handle(AutoPinnedTransientReq request, CancellationToken ct) => new(_dep is null ? -1 : 42); +} + +[HandlerLifetime(HandlerLifetime.Singleton)] +public sealed class AutoPinnedSingletonReqHandler(AutoSingletonDep dep) : IRequestHandler +{ + private readonly AutoSingletonDep _dep = dep; + public ValueTask Handle(AutoPinnedSingletonReq request, CancellationToken ct) => new(_dep is null ? -1 : 42); +} +public sealed record AutoPinnedSingletonReq : IRequest; + +/// +/// End-to-end coverage of the generator-driven smart handler lifetime: the generated +/// RegisterMediatorHandlers() emits the call, which raises +/// a dependency-carrying handler from the conservative Transient default to the longest safe lifetime its +/// dependencies allow - unless pinned with [HandlerLifetime]. Each test registers the relevant +/// dependency BEFORE RegisterMediatorHandlers() (the normal composition-root order) so the pass sees it. +/// +public class HandlerLifetimeAutoDetectionTests +{ + private static ServiceLifetime HandlerLifetimeOf(IServiceCollection services) + where TReq : IRequest => + services.Last(d => d.ServiceType == typeof(IRequestHandler)).Lifetime; + + [Fact] + public void SingletonDependency_AutoUpgradesHandlerToSingleton_AndReusesAcrossScopes() + { + var services = new ServiceCollection(); + services.AddSingleton(); // dep registered before the mediator + services.AddMediator().RegisterMediatorHandlers().PrecompilePipelines(); // upgrade applied at finalization + + HandlerLifetimeOf(services) + .ShouldBe(ServiceLifetime.Singleton, "all-singleton deps -> handler raised to Singleton"); + + // Runtime proof: one shared instance across scopes (zero per-request handler allocation). + using var provider = services.BuildServiceProvider(); + Guid id1, id2; + using (var s1 = provider.CreateScope()) + id1 = ((AutoSingletonReqHandler)s1.ServiceProvider.GetRequiredService>()).InstanceId; + using (var s2 = provider.CreateScope()) + id2 = ((AutoSingletonReqHandler)s2.ServiceProvider.GetRequiredService>()).InstanceId; + + id1.ShouldBe(id2, "auto-Singleton handler is shared across scopes"); + } + + [Fact] + public void DependencyRegisteredAfterRegisterHandlers_ButBeforePrecompile_IsUpgraded() + { + var services = new ServiceCollection(); + services.AddMediator().RegisterMediatorHandlers(); // handler staged; dependency not yet registered + services.AddSingleton(); // realistic order: infrastructure registered AFTER + services.PrecompilePipelines(); // finalization sees the dependency and upgrades + + HandlerLifetimeOf(services) + .ShouldBe( + ServiceLifetime.Singleton, + "a dependency registered after RegisterMediatorHandlers but before finalization is still seen"); + } + + [Fact] + public void ScopedDependency_AutoCapsHandlerAtScoped() + { + var services = new ServiceCollection(); + services.AddScoped(); + services.AddMediator().RegisterMediatorHandlers().PrecompilePipelines(); + + HandlerLifetimeOf(services) + .ShouldBe(ServiceLifetime.Scoped, "a scoped dep caps the handler at Scoped"); + } + + [Fact] + public void UnregisteredDependency_LeavesHandlerTransient() + { + var services = new ServiceCollection(); + // AutoSingletonDep deliberately NEVER registered. + services.AddMediator().RegisterMediatorHandlers().PrecompilePipelines(); + + HandlerLifetimeOf(services) + .ShouldBe(ServiceLifetime.Transient, "an unknown dep keeps the conservative Transient default"); + } + + [Fact] + public void PinnedTransient_StaysTransient_DespiteSingletonDependency() + { + var services = new ServiceCollection(); + services.AddSingleton(); + services.AddMediator().RegisterMediatorHandlers().PrecompilePipelines(); + + HandlerLifetimeOf(services) + .ShouldBe(ServiceLifetime.Transient, "[HandlerLifetime(Transient)] opts out of the upgrade"); + } + + [Fact] + public void PinnedSingleton_ForcesSingleton_EvenWithUnregisteredDependency() + { + var services = new ServiceCollection(); + // AutoSingletonDep NOT registered: auto-detection would stay Transient, but the attribute forces Singleton. + services.AddMediator().RegisterMediatorHandlers().PrecompilePipelines(); + + HandlerLifetimeOf(services) + .ShouldBe(ServiceLifetime.Singleton, "[HandlerLifetime(Singleton)] pins Singleton regardless of deps"); + } +} diff --git a/tests/DSoftStudio.Mediator.Tests/Validation/HandlerValidationTests.cs b/tests/DSoftStudio.Mediator.Tests/Validation/HandlerValidationTests.cs index 0dbe298..5f3816d 100644 --- a/tests/DSoftStudio.Mediator.Tests/Validation/HandlerValidationTests.cs +++ b/tests/DSoftStudio.Mediator.Tests/Validation/HandlerValidationTests.cs @@ -40,6 +40,10 @@ public void ValidateMediatorHandlers_AllRegistered_DoesNotThrow() services.AddSingleton(new Integration.ThreadSafeChaosRandom()); services.AddSingleton(new Integration.ChaosConfig()); + // Dependencies required by the auto-lifetime-detection test handlers (Lifetimes/) + services.AddSingleton(); + services.AddScoped(); + using var provider = services.BuildServiceProvider(); // Act & Assert — should not throw From 666c348ce07d8a74a1890bae474c1313220308b8 Mon Sep 17 00:00:00 2001 From: Yander Santiesteban Rojas Date: Thu, 25 Jun 2026 17:34:11 -0300 Subject: [PATCH 3/5] perf(dispatch): keep PipelineChainHandler hot path flat under the observer seam Handle() delegated the 3-way dispatch switch to HandleCore(); despite the AggressiveInlining hint the JIT did not inline it, adding a real call to every chain dispatch. Peel the cold observer path off first and run the switch inline in Handle() (as it was pre-seam); HandleCore() now serves only the cold observer paths. The no-observer hot path is back to a single field-null check + the inline switch. No behavior change: 555 OSS tests green (incl. 101 OpenTelemetry adapter tests). --- .../PipelineChainHandler.cs | 26 +++++++++++++------ 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/src/DSoftStudio.Mediator/PipelineChainHandler.cs b/src/DSoftStudio.Mediator/PipelineChainHandler.cs index e510309..be13ff7 100644 --- a/src/DSoftStudio.Mediator/PipelineChainHandler.cs +++ b/src/DSoftStudio.Mediator/PipelineChainHandler.cs @@ -105,11 +105,21 @@ private static byte ComputePipelineMode( [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] public ValueTask Handle(TRequest request, CancellationToken cancellationToken) { - // HOT path: the only cost the dispatch port adds to a non-OTel app is this single field-null check. - // `_observer` is null → straight to HandleCore, whose switch the JIT inlines right here (both this - // method and HandleCore are AggressiveInlining), so the dispatch stays as tight as the pre-observer - // version. The `IsActive` interface call lives in the COLD HandleWithObserver, never in this method. - return (_observer is null) ? HandleCore(request, cancellationToken) : HandleWithObserver(request, cancellationToken); + // Peel the COLD observer path off first; the common (no-adapter) path is then a single predictable + // null-test followed by the 3-way switch laid out INLINE here — byte-for-byte the pre-observer hot + // path. Delegating the switch to a separate HandleCore() did NOT inline in practice: the JIT + // declined the AggressiveInlining hint and emitted a real call, adding ~2.3 ns to EVERY chain + // dispatch (measured back-to-back vs. the pre-observer build). So the switch lives directly in + // Handle; the observer paths reuse HandleCore, where a call is cold and irrelevant. + if (_observer is not null) + return HandleWithObserver(request, cancellationToken); + + return _pipelineMode switch + { + 0 => _handler.Handle(request, cancellationToken), + 1 => HandleBehaviorsOnly(request, cancellationToken), + _ => HandleFull(request, cancellationToken), + }; } /// @@ -129,9 +139,9 @@ private ValueTask HandleWithObserver(TRequest request, CancellationTo : HandleCore(request, cancellationToken); /// - /// The single 3-way dispatch switch, shared by the hot path ( delegates here) and - /// the cold observer paths. lets the JIT inline the - /// switch into , so the delegation costs nothing on the non-observed fast path. + /// The 3-way dispatch switch for the COLD observer paths only ( and + /// ). The hot path in carries its own inline copy of + /// this switch — see the note there for why it is not delegated here. /// [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] private ValueTask HandleCore(TRequest request, CancellationToken cancellationToken) From 92b05e441a3e71efe4222db303808914ec42bcd7 Mon Sep 17 00:00:00 2001 From: Yander Santiesteban Rojas Date: Sun, 28 Jun 2026 12:25:45 -0300 Subject: [PATCH 4/5] chore(release): bump package versions for the dispatch-observer + handler-lifetime release core + Abstractions 1.3.0-rc.1 -> 1.3.0-rc.2; OpenTelemetry 1.1.0-rc.2 -> 1.1.0-rc.3; FluentValidation + HybridCache 1.0.9-rc.1 -> 1.0.9-rc.2. Version-string only; carries the already-committed dispatch-observer seam + order-independent handler DI-lifetime auto-detection. Build green (0 errors). --- .../DSoftStudio.Mediator.Abstractions.csproj | 2 +- .../DSoftStudio.Mediator.FluentValidation.csproj | 2 +- .../DSoftStudio.Mediator.HybridCache.csproj | 2 +- .../DSoftStudio.Mediator.OpenTelemetry.csproj | 2 +- src/DSoftStudio.Mediator/DSoftStudio.Mediator.csproj | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/DSoftStudio.Mediator.Abstractions/DSoftStudio.Mediator.Abstractions.csproj b/src/DSoftStudio.Mediator.Abstractions/DSoftStudio.Mediator.Abstractions.csproj index 94ec7ca..6115d03 100644 --- a/src/DSoftStudio.Mediator.Abstractions/DSoftStudio.Mediator.Abstractions.csproj +++ b/src/DSoftStudio.Mediator.Abstractions/DSoftStudio.Mediator.Abstractions.csproj @@ -8,7 +8,7 @@ DSoftStudio.Mediator.Abstractions - 1.3.0-rc.1 + 1.3.0-rc.2 DSoftStudio DSoftStudio diff --git a/src/DSoftStudio.Mediator.FluentValidation/DSoftStudio.Mediator.FluentValidation.csproj b/src/DSoftStudio.Mediator.FluentValidation/DSoftStudio.Mediator.FluentValidation.csproj index 4b55a9b..2c02f5d 100644 --- a/src/DSoftStudio.Mediator.FluentValidation/DSoftStudio.Mediator.FluentValidation.csproj +++ b/src/DSoftStudio.Mediator.FluentValidation/DSoftStudio.Mediator.FluentValidation.csproj @@ -9,7 +9,7 @@ DSoftStudio.Mediator.FluentValidation - 1.0.9-rc.1 + 1.0.9-rc.2 DSoftStudio DSoftStudio diff --git a/src/DSoftStudio.Mediator.HybridCache/DSoftStudio.Mediator.HybridCache.csproj b/src/DSoftStudio.Mediator.HybridCache/DSoftStudio.Mediator.HybridCache.csproj index 357348c..53a75a8 100644 --- a/src/DSoftStudio.Mediator.HybridCache/DSoftStudio.Mediator.HybridCache.csproj +++ b/src/DSoftStudio.Mediator.HybridCache/DSoftStudio.Mediator.HybridCache.csproj @@ -9,7 +9,7 @@ DSoftStudio.Mediator.HybridCache - 1.0.9-rc.1 + 1.0.9-rc.2 DSoftStudio DSoftStudio diff --git a/src/DSoftStudio.Mediator.OpenTelemetry/DSoftStudio.Mediator.OpenTelemetry.csproj b/src/DSoftStudio.Mediator.OpenTelemetry/DSoftStudio.Mediator.OpenTelemetry.csproj index 99d0063..df20a5e 100644 --- a/src/DSoftStudio.Mediator.OpenTelemetry/DSoftStudio.Mediator.OpenTelemetry.csproj +++ b/src/DSoftStudio.Mediator.OpenTelemetry/DSoftStudio.Mediator.OpenTelemetry.csproj @@ -9,7 +9,7 @@ DSoftStudio.Mediator.OpenTelemetry - 1.1.0-rc.2 + 1.1.0-rc.3 DSoftStudio DSoftStudio diff --git a/src/DSoftStudio.Mediator/DSoftStudio.Mediator.csproj b/src/DSoftStudio.Mediator/DSoftStudio.Mediator.csproj index a717e92..84af492 100644 --- a/src/DSoftStudio.Mediator/DSoftStudio.Mediator.csproj +++ b/src/DSoftStudio.Mediator/DSoftStudio.Mediator.csproj @@ -14,7 +14,7 @@ DSoftStudio.Mediator - 1.3.0-rc.1 + 1.3.0-rc.2 DSoftStudio DSoftStudio From 656c014532dadcd29fec8bb4caa37e82ff2c8c09 Mon Sep 17 00:00:00 2001 From: Yander Santiesteban Rojas Date: Sun, 28 Jun 2026 12:34:20 -0300 Subject: [PATCH 5/5] docs(abstractions): write a literal & in IMediatorDispatchObserver summary (Ports & Adapters) --- .../IMediatorDispatchObserver.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/DSoftStudio.Mediator.Abstractions/IMediatorDispatchObserver.cs b/src/DSoftStudio.Mediator.Abstractions/IMediatorDispatchObserver.cs index e25ebdc..33532a3 100644 --- a/src/DSoftStudio.Mediator.Abstractions/IMediatorDispatchObserver.cs +++ b/src/DSoftStudio.Mediator.Abstractions/IMediatorDispatchObserver.cs @@ -6,7 +6,7 @@ namespace DSoftStudio.Mediator.Abstractions; /// -/// Optional observation port for the request-dispatch boundary (Ports & Adapters). +/// Optional observation port for the request-dispatch boundary (Ports & Adapters). /// /// The mediator itself does NOT observe or trace — it merely EXPOSES the dispatch lifecycle so an external /// adapter (e.g. the OpenTelemetry bridge) can wrap the WHOLE pipeline: pre-processors, behaviors, handler