From 93fa1546d41a9feffa2c67f9332a8184560581c4 Mon Sep 17 00:00:00 2001 From: Vulthil Date: Mon, 25 May 2026 12:33:47 +0200 Subject: [PATCH 01/42] refactor(application): decorate handlers directly instead of relying on ISender Move the CQRS pipeline from ISender-only dispatch to direct handler decoration. Pipeline behaviors are now composed lazily at handler-resolution time via a PipelineHandlerDecorator wrapping every registered ICommandHandler/IQueryHandler/ IDomainEventHandler, so callers can inject the typed handler interface (e.g. ICommandHandler) and still receive the full pipeline (validation, logging, transactional behavior, etc.) without going through ISender. - Add HandlerRegistrar, HandlerInterfaceAdapters, IInnerHandler and PipelineHandlerDecorator under Vulthil.SharedKernel.Application/Pipeline. - Replace Scrutor-based assembly scanning with explicit registration; drop the Scrutor package reference. - Expose AddOpenPipelineHandler / AddOpenDomainEventPipelineHandler for open-generic behavior registration; behaviors apply regardless of when they are registered relative to AddHandlers. - Update the WebApi sample to inject typed handlers in its endpoints, group routes under /api, and rename the route prefix to /main-entities. - Add HttpResponseMessageExtensions.GetResponseAsync in Vulthil.Extensions.Testing for the refreshed integration tests. - Document the new pipeline shape in docs/articles/cqrs-pipeline.md and the Vulthil.SharedKernel.Application package doc. Co-Authored-By: Claude Opus 4.7 (1M context) --- Directory.Packages.props | 1 - docs/articles/cqrs-pipeline.md | 25 +++ .../vulthil-sharedkernel-application.md | 14 ++ .../GetById/GetMainEntityByIdQuery.cs | 2 +- .../Data/WebApiDbContext.cs | 35 +-- .../Fixtures/BaseIntegrationTestCase.cs | 6 - .../Fixtures/CustomWebApplicationFactory.cs | 3 - .../MainEntityIntegrationTests.cs | 28 ++- samples/WebApi/WebApi/MainEntity/Create.cs | 4 +- samples/WebApi/WebApi/MainEntity/GetAll.cs | 2 +- samples/WebApi/WebApi/MainEntity/GetById.cs | 5 +- samples/WebApi/WebApi/Program.cs | 10 +- .../HttpResponseMessageExtensions.cs | 26 +++ .../PublicAPI.Unshipped.txt | 2 + .../ApplicationOptions.cs | 22 ++ .../DependencyInjection.cs | 97 +++++++-- .../DomainEvents/DomainEventPublisher.cs | 2 +- .../Messaging/ISender.cs | 23 +- .../Pipeline/HandlerInterfaceAdapters.cs | 50 +++++ .../Pipeline/HandlerRegistrar.cs | 156 +++++++++++++ .../Pipeline/IInnerHandler.cs | 24 ++ .../Pipeline/PipelineHandlerDecorator.cs | 37 ++++ .../PublicAPI.Unshipped.txt | 4 + .../Vulthil.SharedKernel.Application.csproj | 1 - .../Pipeline/HandlerRegistrationTests.cs | 206 ++++++++++++++++++ 25 files changed, 679 insertions(+), 106 deletions(-) create mode 100644 src/Vulthil.Extensions.Testing/HttpResponseMessageExtensions.cs create mode 100644 src/Vulthil.SharedKernel.Application/Pipeline/HandlerInterfaceAdapters.cs create mode 100644 src/Vulthil.SharedKernel.Application/Pipeline/HandlerRegistrar.cs create mode 100644 src/Vulthil.SharedKernel.Application/Pipeline/IInnerHandler.cs create mode 100644 src/Vulthil.SharedKernel.Application/Pipeline/PipelineHandlerDecorator.cs create mode 100644 tests/Vulthil.SharedKernel.Application.Tests/Pipeline/HandlerRegistrationTests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 5f507ef..757032e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -52,7 +52,6 @@ - diff --git a/docs/articles/cqrs-pipeline.md b/docs/articles/cqrs-pipeline.md index 22deb21..89d5878 100644 --- a/docs/articles/cqrs-pipeline.md +++ b/docs/articles/cqrs-pipeline.md @@ -121,3 +121,28 @@ app.MapPost("/users", async (CreateUserCommand command, ISender sender) => return result.ToIResult(); }); ``` + +### Direct handler injection + +You can also inject the handler interface directly. The resolved instance is the same pipeline-wrapped handler that `ISender` would dispatch to, so behaviors (validation, logging, transactions, custom ones) apply uniformly either way. + +```csharp +app.MapPost("/users", async ( + CreateUserCommand command, + ICommandHandler> handler) => +{ + var result = await handler.HandleAsync(command); + return result.ToIResult(); +}); +``` + +The interfaces you can inject are `IHandler`, `ICommandHandler`, `ICommandHandler` (for `Result`-returning commands) and `IQueryHandler`. The concrete handler implementation is *not* registered as a DI service — there is no way to bypass the pipeline by injecting the concrete type. + +## Behaviors across assemblies + +Pipeline behaviors are composed at handler-resolution time, not at registration time, so the order of registration is irrelevant. Different assemblies may register handlers and behaviors independently — all behaviors registered before `BuildServiceProvider` apply to all handlers resolved afterwards. + +```csharp +// In an Infrastructure assembly: +services.AddOpenPipelineHandler(typeof(MyCustomBehavior<,>)); +``` diff --git a/docs/articles/packages/vulthil-sharedkernel-application.md b/docs/articles/packages/vulthil-sharedkernel-application.md index ccf977b..4ef33d2 100644 --- a/docs/articles/packages/vulthil-sharedkernel-application.md +++ b/docs/articles/packages/vulthil-sharedkernel-application.md @@ -85,3 +85,17 @@ public sealed class CreateUserCommandValidator : AbstractValidator`, `ICommandHandler`, `IQueryHandler` and `IHandler` can also be injected directly. The resolved instance shares the same pipeline as `ISender`, so any registered behavior (validation, logging, transactions, custom) still applies. + +```csharp +public sealed class CreateUserEndpoint(ICommandHandler> handler) +{ + public Task> ExecuteAsync(CreateUserCommand command, CancellationToken ct) + => handler.HandleAsync(command, ct); +} +``` + +Custom behaviors registered from any assembly with `services.AddOpenPipelineHandler(typeof(MyBehavior<,>))` apply to every handler resolved afterwards — order of registration relative to `AddApplication` does not matter. diff --git a/samples/WebApi/WebApi.Application/MainEntities/GetById/GetMainEntityByIdQuery.cs b/samples/WebApi/WebApi.Application/MainEntities/GetById/GetMainEntityByIdQuery.cs index d9bbec3..2b761fa 100644 --- a/samples/WebApi/WebApi.Application/MainEntities/GetById/GetMainEntityByIdQuery.cs +++ b/samples/WebApi/WebApi.Application/MainEntities/GetById/GetMainEntityByIdQuery.cs @@ -56,7 +56,7 @@ public async Task> HandleAsync(GetMainEntityByIdQuery quer _logger.LogInformation("Querying MainEntity With Id: {Id}", query.Id); var id = new MainEntityId(query.Id); - return await _dbContext.MainEntities.FirstOrDefaultAsync(w => w.Id == id) + return await _dbContext.MainEntities.FirstOrDefaultAsync(w => w.Id == id, cancellationToken) .ToResultAsync(MainEntityErrors.NotFound(query.Id)) .BindAsync(m => _requester.RequestAsync>(new GetSideEffectsBelongingToMainEntity(id.Value), cancellationToken: cancellationToken) .MapAsync(sideEffects => (m, sideEffects))) diff --git a/samples/WebApi/WebApi.Infrastructure/Data/WebApiDbContext.cs b/samples/WebApi/WebApi.Infrastructure/Data/WebApiDbContext.cs index fe5ae56..84db731 100644 --- a/samples/WebApi/WebApi.Infrastructure/Data/WebApiDbContext.cs +++ b/samples/WebApi/WebApi.Infrastructure/Data/WebApiDbContext.cs @@ -18,17 +18,8 @@ namespace WebApi.Infrastructure.Data; /// public sealed class WebApiDbContext(DbContextOptions options) : BaseDbContext(options), IWebApiDbContext { - /// - /// Executes this member. - /// public DbSet MainEntities => Set(); - /// - /// Executes this member. - /// public DbSet SideEffects => Set(); - /// - /// Executes this member. - /// protected override Assembly? ConfigurationAssembly => typeof(WebApiDbContext).Assembly; } @@ -36,24 +27,12 @@ public sealed class WebApiDbContext(DbContextOptions options) : /// Functionally equivalent with , without inheriting from . /// /// -public sealed class WebApiDbContextNoBase(DbContextOptions options) : DbContext(options), IUnitOfWork, ISaveOutboxMessages, IWebApiDbContext +public sealed class WebApiDbContextNoBase(DbContextOptions options) : DbContext(options), ISaveOutboxMessages, IWebApiDbContext { - /// - /// Executes this member. - /// public DbSet MainEntities => Set(); - /// - /// Executes this member. - /// public DbSet SideEffects => Set(); - /// - /// Executes this member. - /// public DbSet OutboxMessages => Set(); - /// - /// Executes this member. - /// protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); @@ -61,17 +40,11 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.ApplyConfigurationsFromAssembly(typeof(WebApiDbContextNoBase).Assembly); } - /// - /// Executes this member. - /// public async Task BeginTransactionAsync(CancellationToken cancellationToken = default) => new DbContextTransactionWrapper(await Database.BeginTransactionAsync(cancellationToken)); } internal sealed class OutboxMessageEntityConfiguration : IEntityTypeConfiguration { - /// - /// Executes this member. - /// public void Configure(EntityTypeBuilder builder) { builder.HasKey(o => o.Id); @@ -84,14 +57,8 @@ public void Configure(EntityTypeBuilder builder) } } -/// -/// Represents the WebApiDbContextFactory. -/// public class WebApiDbContextFactory : IDesignTimeDbContextFactory { - /// - /// Executes this member. - /// public WebApiDbContext CreateDbContext(string[] args) { var optionsBuilder = new DbContextOptionsBuilder(); diff --git a/samples/WebApi/WebApi.Tests/Fixtures/BaseIntegrationTestCase.cs b/samples/WebApi/WebApi.Tests/Fixtures/BaseIntegrationTestCase.cs index 159ee4d..ed47efe 100644 --- a/samples/WebApi/WebApi.Tests/Fixtures/BaseIntegrationTestCase.cs +++ b/samples/WebApi/WebApi.Tests/Fixtures/BaseIntegrationTestCase.cs @@ -4,13 +4,7 @@ namespace WebApi.Tests.Fixtures; -/// -/// Represents the BaseIntegrationTestCase. -/// public abstract class BaseIntegrationTestCase(FixtureWrapper testFixture, ITestOutputHelper testOutputHelper) : BaseIntegrationTestCase(testFixture, testOutputHelper), IClassFixture { - /// - /// Executes this member. - /// protected ISender Sender => ScopedServices.GetRequiredService(); } diff --git a/samples/WebApi/WebApi.Tests/Fixtures/CustomWebApplicationFactory.cs b/samples/WebApi/WebApi.Tests/Fixtures/CustomWebApplicationFactory.cs index 0619624..91873cc 100644 --- a/samples/WebApi/WebApi.Tests/Fixtures/CustomWebApplicationFactory.cs +++ b/samples/WebApi/WebApi.Tests/Fixtures/CustomWebApplicationFactory.cs @@ -2,7 +2,4 @@ namespace WebApi.Tests.Fixtures; -/// -/// Represents the CustomWebApplicationFactory. -/// public sealed class CustomWebApplicationFactory : BaseWebApplicationFactory; diff --git a/samples/WebApi/WebApi.Tests/MainEntityIntegrationTests.cs b/samples/WebApi/WebApi.Tests/MainEntityIntegrationTests.cs index 42c7631..4449f7d 100644 --- a/samples/WebApi/WebApi.Tests/MainEntityIntegrationTests.cs +++ b/samples/WebApi/WebApi.Tests/MainEntityIntegrationTests.cs @@ -1,3 +1,4 @@ +using System.Net.Http.Json; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Shouldly; @@ -9,19 +10,28 @@ using WebApi.Application.MainEntities.Update; using WebApi.Domain.MainEntities.Events; using WebApi.Infrastructure.Data; +using WebApi.MainEntity; using WebApi.Tests.Fixtures; namespace WebApi.Tests; -/// -/// Represents the MainEntityIntegrationTests. -/// public sealed class MainEntityIntegrationTests(FixtureWrapper testFixture, ITestOutputHelper testOutputHelper) : BaseIntegrationTestCase(testFixture, testOutputHelper) { - /// - /// Executes this member. - /// + [Fact] + public async Task Test_Create_Endpoint() + { + // Arrange + var client = Client; + var response = await client.PostAsJsonAsync("api/main-entities", new Create.Request("Test Name"), CancellationToken); + + // Act + var result = await response.GetResponseAsync(CancellationToken); + + // Assert + result.Id.ShouldNotBe(Guid.Empty); + } + [Fact] public async Task Test_Create() { @@ -35,9 +45,6 @@ public async Task Test_Create() result.IsSuccess.ShouldBeTrue(); } - /// - /// Executes this member. - /// [Fact] public async Task Test_Get() { @@ -55,9 +62,6 @@ public async Task Test_Get() queryResult.Value.Name.ShouldBe(command.Name); } - /// - /// Executes this member. - /// [Fact] public async Task Test_Update() { diff --git a/samples/WebApi/WebApi/MainEntity/Create.cs b/samples/WebApi/WebApi/MainEntity/Create.cs index b6ad164..7a5ea68 100644 --- a/samples/WebApi/WebApi/MainEntity/Create.cs +++ b/samples/WebApi/WebApi/MainEntity/Create.cs @@ -32,10 +32,10 @@ public class Endpoint : IEndpoint /// public void MapEndpoint(IEndpointRouteBuilder app) { - app.MapPost("mainentity", async Task, ValidationProblem, NotFound, Conflict, ProblemHttpResult>> (ISender sender, Request request) => + app.MapPost("main-entities", async Task, ValidationProblem, NotFound, Conflict, ProblemHttpResult>> (ICommandHandler> handler, Request request) => { var command = new CreateMainEntityCommand(request.Name); - var result = await sender.SendAsync(command); + var result = await handler.HandleAsync(command); return result .Map(id => new Response(id)) .ToCreatedAtRouteHttpResult("GetMainEntity", r => r); diff --git a/samples/WebApi/WebApi/MainEntity/GetAll.cs b/samples/WebApi/WebApi/MainEntity/GetAll.cs index 74d7767..165438b 100644 --- a/samples/WebApi/WebApi/MainEntity/GetAll.cs +++ b/samples/WebApi/WebApi/MainEntity/GetAll.cs @@ -23,7 +23,7 @@ public class Endpoint : IEndpoint /// public void MapEndpoint(IEndpointRouteBuilder app) { - app.MapGet("mainentity", async (ISender sender) => + app.MapGet("main-entities", async (ISender sender) => { var query = new GetMainEntities(); var result = await sender.SendAsync(query); diff --git a/samples/WebApi/WebApi/MainEntity/GetById.cs b/samples/WebApi/WebApi/MainEntity/GetById.cs index 9d8e2c5..0bb2137 100644 --- a/samples/WebApi/WebApi/MainEntity/GetById.cs +++ b/samples/WebApi/WebApi/MainEntity/GetById.cs @@ -1,3 +1,4 @@ +using Vulthil.Results; using Vulthil.SharedKernel.Api; using Vulthil.SharedKernel.Application.Messaging; using WebApi.Application.MainEntities.GetById; @@ -19,10 +20,10 @@ public class Endpoint : IEndpoint /// public void MapEndpoint(IEndpointRouteBuilder app) { - app.MapGet("mainentity/{id:guid}", async (ISender sender, Guid id) => + app.MapGet("main-entities/{id:guid}", async (IQueryHandler> sender, Guid id) => { var query = new GetMainEntityByIdQuery(id); - var result = await sender.SendAsync(query); + var result = await sender.HandleAsync(query); return result.ToIResult(); }) .WithName("GetMainEntity"); diff --git a/samples/WebApi/WebApi/Program.cs b/samples/WebApi/WebApi/Program.cs index adee269..b82e18c 100644 --- a/samples/WebApi/WebApi/Program.cs +++ b/samples/WebApi/WebApi/Program.cs @@ -29,7 +29,8 @@ app.UseAuthorization(); app.MapControllers(); -app.MapEndpoints(); +var apiGroup = app.MapGroup("api").WithTags("API Endpoints"); +app.MapEndpoints(apiGroup); if (app.Environment.IsDevelopment()) { @@ -38,10 +39,3 @@ } await app.RunAsync(); - -#pragma warning disable S1118 // Utility classes should not have public constructors -/// -/// Represents the Program. -/// -public partial class Program; -#pragma warning restore S1118 // Utility classes should not have public constructors diff --git a/src/Vulthil.Extensions.Testing/HttpResponseMessageExtensions.cs b/src/Vulthil.Extensions.Testing/HttpResponseMessageExtensions.cs new file mode 100644 index 0000000..4f85d5b --- /dev/null +++ b/src/Vulthil.Extensions.Testing/HttpResponseMessageExtensions.cs @@ -0,0 +1,26 @@ +using System.Net.Http.Json; + +namespace Vulthil.Extensions.Testing; + +/// +/// Extension methods for to simplify testing. +/// +public static class HttpResponseMessageExtensions +{ + /// + /// Reads the JSON content of the HTTP response and deserializes it into the specified type. + /// + /// + /// + /// + /// + public static async Task GetResponseAsync(this HttpResponseMessage? response, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(response); + response.EnsureSuccessStatusCode(); + + var result = await response.Content.ReadFromJsonAsync(cancellationToken); + + return result!; + } +} diff --git a/src/Vulthil.Extensions.Testing/PublicAPI.Unshipped.txt b/src/Vulthil.Extensions.Testing/PublicAPI.Unshipped.txt index 862f2d5..44ea1a8 100644 --- a/src/Vulthil.Extensions.Testing/PublicAPI.Unshipped.txt +++ b/src/Vulthil.Extensions.Testing/PublicAPI.Unshipped.txt @@ -1,5 +1,6 @@ #nullable enable static readonly Vulthil.Extensions.Testing.Polling.Timeout -> Vulthil.Results.Error! +static Vulthil.Extensions.Testing.HttpResponseMessageExtensions.GetResponseAsync(this System.Net.Http.HttpResponseMessage? response, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! static Vulthil.Extensions.Testing.Polling.WaitAsync(System.TimeSpan timeout, System.Func!>! func) -> System.Threading.Tasks.Task! static Vulthil.Extensions.Testing.Polling.WaitAsync(System.TimeSpan timeout, System.Func!>! func, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! static Vulthil.Extensions.Testing.Polling.WaitAsync(System.TimeSpan timeout, System.Func!>! func, System.TimeSpan timerTick, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! @@ -11,6 +12,7 @@ static Vulthil.Extensions.Testing.Polling.WaitAsync(System.TimeSpan timeout, static Vulthil.Extensions.Testing.Polling.WaitAsync(System.TimeSpan timeout, System.Func!>!>! func, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task!>! static Vulthil.Extensions.Testing.Polling.WaitAsync(System.TimeSpan timeout, System.Func!>!>! func, System.TimeSpan timerTick, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task!>! static Vulthil.Extensions.Testing.PollingError.FromErrors(System.Collections.Generic.IEnumerable! errors) -> Vulthil.Extensions.Testing.PollingError! +Vulthil.Extensions.Testing.HttpResponseMessageExtensions Vulthil.Extensions.Testing.Polling Vulthil.Extensions.Testing.PollingError Vulthil.Extensions.Testing.PollingError.Errors.get -> Vulthil.Results.Error![]! diff --git a/src/Vulthil.SharedKernel.Application/ApplicationOptions.cs b/src/Vulthil.SharedKernel.Application/ApplicationOptions.cs index d7780bd..0ee51cf 100644 --- a/src/Vulthil.SharedKernel.Application/ApplicationOptions.cs +++ b/src/Vulthil.SharedKernel.Application/ApplicationOptions.cs @@ -207,4 +207,26 @@ public ApplicationOptions AddTransactionalPipelineBehavior() HandlerOptions.AddOpenPipelineHandler(typeof(TransactionalPipelineBehavior<,>)); return this; } + + /// + /// Registers an open-generic request pipeline handler type. + /// + /// The open-generic type implementing . + /// The current options instance for chaining. + public ApplicationOptions AddOpenPipelineHandler(Type pipelineHandler) + { + HandlerOptions.AddOpenPipelineHandler(pipelineHandler); + return this; + } + + /// + /// Registers an open-generic domain event pipeline handler type. + /// + /// The open-generic type implementing . + /// The current options instance for chaining. + public ApplicationOptions AddOpenDomainEventPipelineHandler(Type pipelineHandler) + { + HandlerOptions.AddOpenDomainEventPipelineHandler(pipelineHandler); + return this; + } } diff --git a/src/Vulthil.SharedKernel.Application/DependencyInjection.cs b/src/Vulthil.SharedKernel.Application/DependencyInjection.cs index 4432604..d88fdf7 100644 --- a/src/Vulthil.SharedKernel.Application/DependencyInjection.cs +++ b/src/Vulthil.SharedKernel.Application/DependencyInjection.cs @@ -3,7 +3,7 @@ using Microsoft.Extensions.DependencyInjection.Extensions; using Vulthil.SharedKernel.Application.Messaging; using Vulthil.SharedKernel.Application.Messaging.DomainEvents; -using Vulthil.SharedKernel.Events; +using Vulthil.SharedKernel.Application.Pipeline; namespace Vulthil.SharedKernel.Application; @@ -18,10 +18,7 @@ public static class DependencyInjection /// /// The service collection. /// The service collection for chaining. - public static IServiceCollection AddApplication(this IServiceCollection services) - { - return services.AddApplication(new ApplicationOptions()); - } + public static IServiceCollection AddApplication(this IServiceCollection services) => services.AddApplication(new ApplicationOptions()); /// /// Registers application-layer services including handlers and FluentValidation validators. @@ -55,10 +52,7 @@ public static IServiceCollection AddApplication(this IServiceCollection services /// /// The service collection. /// The service collection for chaining. - public static IServiceCollection AddFluentValidation(this IServiceCollection services) - { - return services.AddFluentValidation(new FluentValidationOptions()); - } + public static IServiceCollection AddFluentValidation(this IServiceCollection services) => services.AddFluentValidation(new FluentValidationOptions()); /// @@ -95,10 +89,7 @@ public static IServiceCollection AddFluentValidation(this IServiceCollection ser /// /// The service collection. /// The service collection for chaining. - public static IServiceCollection AddHandlers(this IServiceCollection services) - { - return services.AddHandlers(new HandlerOptions()); - } + public static IServiceCollection AddHandlers(this IServiceCollection services) => services.AddHandlers(new HandlerOptions()); /// /// Registers request handlers, domain event handlers, and pipeline handlers from the configured assemblies. @@ -131,9 +122,7 @@ public static IServiceCollection AddHandlers(this IServiceCollection services, H services.TryAddScoped(); services.TryAddScoped(); - services.Scan(s => s.FromAssemblies(handlerOptions.HandlerAssemblies) - .AddClasses(c => c.AssignableTo(typeof(IHandler<,>)), false).AsImplementedInterfaces(t => t.GetGenericTypeDefinition() == typeof(IHandler<,>)).WithScopedLifetime() - .AddClasses(c => c.AssignableTo(typeof(IDomainEventHandler<>)), false).AsImplementedInterfaces().WithScopedLifetime()); + HandlerRegistrar.RegisterHandlersFromAssemblies(services, handlerOptions.HandlerAssemblies); foreach (var item in handlerOptions.PipelineHandlers) { @@ -142,4 +131,80 @@ public static IServiceCollection AddHandlers(this IServiceCollection services, H return services; } + + /// + /// Registers an open-generic request pipeline behavior. Behaviors registered through this method + /// apply to every handler resolved after construction — order of + /// registration relative to is irrelevant because + /// behaviors are composed lazily at handler-resolution time. + /// + /// The service collection. + /// The open-generic type implementing . + /// The service collection for chaining. + /// Thrown when the type is not a valid open-generic . + public static IServiceCollection AddOpenPipelineHandler(this IServiceCollection services, Type pipelineHandler) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(pipelineHandler); + + if (!pipelineHandler.IsGenericTypeDefinition) + { + throw new InvalidOperationException($"{pipelineHandler.Name} must be an open generic type."); + } + + var implementsPipelineHandler = false; + foreach (var iface in pipelineHandler.GetInterfaces()) + { + if (iface.IsGenericType && iface.GetGenericTypeDefinition() == typeof(IPipelineHandler<,>)) + { + implementsPipelineHandler = true; + break; + } + } + + if (!implementsPipelineHandler) + { + throw new InvalidOperationException($"{pipelineHandler.Name} must implement {typeof(IPipelineHandler<,>).FullName}."); + } + + services.TryAddEnumerable(new ServiceDescriptor(typeof(IPipelineHandler<,>), pipelineHandler, ServiceLifetime.Scoped)); + return services; + } + + /// + /// Registers an open-generic domain event pipeline behavior. Behaviors registered through this + /// method apply lazily and are independent of when handlers were registered. + /// + /// The service collection. + /// The open-generic type implementing . + /// The service collection for chaining. + /// Thrown when the type is not a valid open-generic . + public static IServiceCollection AddOpenDomainEventPipelineHandler(this IServiceCollection services, Type pipelineHandler) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(pipelineHandler); + + if (!pipelineHandler.IsGenericTypeDefinition) + { + throw new InvalidOperationException($"{pipelineHandler.Name} must be an open generic type."); + } + + var implementsDomainPipelineHandler = false; + foreach (var iface in pipelineHandler.GetInterfaces()) + { + if (iface.IsGenericType && iface.GetGenericTypeDefinition() == typeof(IDomainEventPipelineHandler<>)) + { + implementsDomainPipelineHandler = true; + break; + } + } + + if (!implementsDomainPipelineHandler) + { + throw new InvalidOperationException($"{pipelineHandler.Name} must implement {typeof(IDomainEventPipelineHandler<>).FullName}."); + } + + services.TryAddEnumerable(new ServiceDescriptor(typeof(IDomainEventPipelineHandler<>), pipelineHandler, ServiceLifetime.Scoped)); + return services; + } } diff --git a/src/Vulthil.SharedKernel.Application/Messaging/DomainEvents/DomainEventPublisher.cs b/src/Vulthil.SharedKernel.Application/Messaging/DomainEvents/DomainEventPublisher.cs index d817c5d..41f86dc 100644 --- a/src/Vulthil.SharedKernel.Application/Messaging/DomainEvents/DomainEventPublisher.cs +++ b/src/Vulthil.SharedKernel.Application/Messaging/DomainEvents/DomainEventPublisher.cs @@ -48,7 +48,7 @@ private static async Task PublishCore(IEnumerable h } catch (Exception ex) { - (exceptions ??= new()).Add(ex); + (exceptions ??= []).Add(ex); } } diff --git a/src/Vulthil.SharedKernel.Application/Messaging/ISender.cs b/src/Vulthil.SharedKernel.Application/Messaging/ISender.cs index 4bb9a9c..1e0d11c 100644 --- a/src/Vulthil.SharedKernel.Application/Messaging/ISender.cs +++ b/src/Vulthil.SharedKernel.Application/Messaging/ISender.cs @@ -1,8 +1,8 @@ using System.Collections.Concurrent; using Microsoft.Extensions.DependencyInjection; -using Vulthil.SharedKernel.Application.Pipeline; namespace Vulthil.SharedKernel.Application.Messaging; + /// /// Dispatches requests to their registered handlers through the pipeline. /// @@ -24,7 +24,6 @@ internal sealed class Sender(IServiceProvider serviceProvider) : ISender private readonly IServiceProvider _serviceProvider = serviceProvider; private static readonly ConcurrentDictionary _requestHandlers = new(); - /// public Task SendAsync(IRequest request, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(request); @@ -42,26 +41,14 @@ public Task SendAsync(IRequest request, Cancell internal class RequestHandlerWrapperResult : IRequestHandlerWrapper where TRequest : IRequest { - /// public async Task HandleAsync(object request, IServiceProvider serviceProvider, CancellationToken cancellationToken = default) => - await HandleAsync((ICommand)request, serviceProvider, cancellationToken).ConfigureAwait(false); + await HandleAsync((IRequest)request, serviceProvider, cancellationToken).ConfigureAwait(false); - /// public Task HandleAsync(IRequest request, IServiceProvider serviceProvider, - CancellationToken cancellationToken = default) - { - Task Handler(CancellationToken t) => serviceProvider.GetRequiredService>() - .HandleAsync((TRequest)request, t); - - var pipeline = serviceProvider - .GetServices>() - .Reverse() - .Aggregate((PipelineDelegate)Handler, - (next, pipeline) => (t) => pipeline.HandleAsync((TRequest)request, next, t)); - - return pipeline(cancellationToken); - } + CancellationToken cancellationToken = default) => + serviceProvider.GetRequiredService>() + .HandleAsync((TRequest)request, cancellationToken); } internal interface IRequestHandlerBase diff --git a/src/Vulthil.SharedKernel.Application/Pipeline/HandlerInterfaceAdapters.cs b/src/Vulthil.SharedKernel.Application/Pipeline/HandlerInterfaceAdapters.cs new file mode 100644 index 0000000..eb59d66 --- /dev/null +++ b/src/Vulthil.SharedKernel.Application/Pipeline/HandlerInterfaceAdapters.cs @@ -0,0 +1,50 @@ +using Vulthil.Results; +using Vulthil.SharedKernel.Application.Messaging; + +namespace Vulthil.SharedKernel.Application.Pipeline; + +/// +/// Forwards resolutions to the +/// pipeline-decorated so direct injection +/// of shares the same pipeline as . +/// +internal sealed class CommandHandlerAdapter(IHandler handler) + : ICommandHandler + where TCommand : ICommand +{ + private readonly IHandler _handler = handler; + + /// + public Task HandleAsync(TCommand request, CancellationToken cancellationToken = default) => + _handler.HandleAsync(request, cancellationToken); +} + +/// +/// Forwards (which produces a ) +/// resolutions to the pipeline-decorated . +/// +internal sealed class CommandHandlerUnitAdapter(IHandler handler) + : ICommandHandler + where TCommand : ICommand +{ + private readonly IHandler _handler = handler; + + /// + public Task HandleAsync(TCommand request, CancellationToken cancellationToken = default) => + _handler.HandleAsync(request, cancellationToken); +} + +/// +/// Forwards resolutions to the +/// pipeline-decorated . +/// +internal sealed class QueryHandlerAdapter(IHandler handler) + : IQueryHandler + where TQuery : IQuery +{ + private readonly IHandler _handler = handler; + + /// + public Task HandleAsync(TQuery request, CancellationToken cancellationToken = default) => + _handler.HandleAsync(request, cancellationToken); +} diff --git a/src/Vulthil.SharedKernel.Application/Pipeline/HandlerRegistrar.cs b/src/Vulthil.SharedKernel.Application/Pipeline/HandlerRegistrar.cs new file mode 100644 index 0000000..cdca7e1 --- /dev/null +++ b/src/Vulthil.SharedKernel.Application/Pipeline/HandlerRegistrar.cs @@ -0,0 +1,156 @@ +using System.Reflection; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Vulthil.Results; +using Vulthil.SharedKernel.Application.Messaging; +using Vulthil.SharedKernel.Events; + +namespace Vulthil.SharedKernel.Application.Pipeline; + +/// +/// Discovers handler implementations in the supplied assemblies and wires them so that +/// every supported handler interface resolves through the pipeline decorator. +/// +/// +/// The concrete handler type is never registered as a public DI service. The decorator +/// resolves the concrete handler via , which means consumers +/// can only reach the handler through the decorated , +/// , +/// or registrations. Pipeline composition +/// happens at resolve time, so behaviors added after handler registration still apply +/// uniformly to every handler that is resolved later. +/// +internal static class HandlerRegistrar +{ + // Reflection over our own private generic helpers — the only way to open the constrained + // generic methods at runtime once the (TRequest, TResponse) types are known. The bypass is + // confined to this file. +#pragma warning disable S3011 + private static readonly MethodInfo RegisterInnerAndDecoratorMethod = + typeof(HandlerRegistrar) + .GetMethod(nameof(RegisterInnerAndDecoratorTyped), BindingFlags.NonPublic | BindingFlags.Static)!; + + private static readonly MethodInfo RegisterCommandAdapterMethod = + typeof(HandlerRegistrar) + .GetMethod(nameof(RegisterCommandAdapterTyped), BindingFlags.NonPublic | BindingFlags.Static)!; + + private static readonly MethodInfo RegisterCommandUnitAdapterMethod = + typeof(HandlerRegistrar) + .GetMethod(nameof(RegisterCommandUnitAdapterTyped), BindingFlags.NonPublic | BindingFlags.Static)!; + + private static readonly MethodInfo RegisterQueryAdapterMethod = + typeof(HandlerRegistrar) + .GetMethod(nameof(RegisterQueryAdapterTyped), BindingFlags.NonPublic | BindingFlags.Static)!; +#pragma warning restore S3011 + + public static void RegisterHandlersFromAssemblies(IServiceCollection services, IEnumerable assemblies) + { + foreach (var assembly in assemblies) + { + foreach (var implType in GetCandidateTypes(assembly)) + { + RegisterRequestHandlerInterfaces(services, implType); + RegisterDomainEventHandlerInterfaces(services, implType); + } + } + } + + private static Type[] GetCandidateTypes(Assembly assembly) + { + Type[] types; + try + { + types = assembly.GetTypes(); + } + catch (ReflectionTypeLoadException ex) + { + types = Array.FindAll(ex.Types, t => t is not null)!; + } + + return Array.FindAll(types, IsConcreteCandidate); + } + + private static bool IsConcreteCandidate(Type type) => + type is { IsClass: true, IsAbstract: false, IsGenericTypeDefinition: false }; + + private static void RegisterRequestHandlerInterfaces(IServiceCollection services, Type implType) + { + Type? handlerInterface = null; + foreach (var iface in implType.GetInterfaces()) + { + if (iface.IsGenericType && iface.GetGenericTypeDefinition() == typeof(IHandler<,>)) + { + handlerInterface = iface; + break; + } + } + + if (handlerInterface is null) + { + return; + } + + var requestType = handlerInterface.GenericTypeArguments[0]; + var responseType = handlerInterface.GenericTypeArguments[1]; + + RegisterInnerAndDecoratorMethod + .MakeGenericMethod(requestType, responseType) + .Invoke(null, [services, implType]); + + if (typeof(ICommand<>).MakeGenericType(responseType).IsAssignableFrom(requestType)) + { + RegisterCommandAdapterMethod + .MakeGenericMethod(requestType, responseType) + .Invoke(null, [services]); + } + + if (typeof(IQuery<>).MakeGenericType(responseType).IsAssignableFrom(requestType)) + { + RegisterQueryAdapterMethod + .MakeGenericMethod(requestType, responseType) + .Invoke(null, [services]); + } + + if (responseType == typeof(Result) && typeof(ICommand).IsAssignableFrom(requestType)) + { + RegisterCommandUnitAdapterMethod + .MakeGenericMethod(requestType) + .Invoke(null, [services]); + } + } + + private static void RegisterInnerAndDecoratorTyped(IServiceCollection services, Type implType) + where TRequest : IRequest + { + services.TryAdd(ServiceDescriptor.Scoped>(sp => + { + var inner = (IHandler)ActivatorUtilities.CreateInstance(sp, implType); + return new InnerHandlerAdapter(inner); + })); + + services.TryAdd(ServiceDescriptor.Scoped, PipelineHandlerDecorator>()); + } + + private static void RegisterCommandAdapterTyped(IServiceCollection services) + where TCommand : ICommand => services.TryAdd(ServiceDescriptor.Scoped>(sp => + new CommandHandlerAdapter(sp.GetRequiredService>()))); + + private static void RegisterCommandUnitAdapterTyped(IServiceCollection services) + where TCommand : ICommand => services.TryAdd(ServiceDescriptor.Scoped>(sp => + new CommandHandlerUnitAdapter(sp.GetRequiredService>()))); + + private static void RegisterQueryAdapterTyped(IServiceCollection services) + where TQuery : IQuery => services.TryAdd(ServiceDescriptor.Scoped>(sp => + new QueryHandlerAdapter(sp.GetRequiredService>()))); + + private static void RegisterDomainEventHandlerInterfaces(IServiceCollection services, Type implType) + { + foreach (var iface in implType.GetInterfaces()) + { + if (iface.IsGenericType && iface.GetGenericTypeDefinition() == typeof(IDomainEventHandler<>)) + { + services.TryAddEnumerable(new ServiceDescriptor(iface, implType, ServiceLifetime.Scoped)); + } + } + } +} diff --git a/src/Vulthil.SharedKernel.Application/Pipeline/IInnerHandler.cs b/src/Vulthil.SharedKernel.Application/Pipeline/IInnerHandler.cs new file mode 100644 index 0000000..87c15c6 --- /dev/null +++ b/src/Vulthil.SharedKernel.Application/Pipeline/IInnerHandler.cs @@ -0,0 +1,24 @@ +using Vulthil.SharedKernel.Application.Messaging; + +namespace Vulthil.SharedKernel.Application.Pipeline; + +/// +/// Internal marker that resolves the concrete handler implementation registered for +/// (, ) without exposing +/// the concrete type to consumers. The +/// depends on this marker so that the only handler interfaces reachable from DI are the +/// pipeline-wrapped ones. +/// +internal interface IInnerHandler + where TRequest : IRequest +{ + Task HandleAsync(TRequest request, CancellationToken cancellationToken = default); +} + +internal sealed class InnerHandlerAdapter(IHandler handler) + : IInnerHandler + where TRequest : IRequest +{ + public Task HandleAsync(TRequest request, CancellationToken cancellationToken = default) => + handler.HandleAsync(request, cancellationToken); +} diff --git a/src/Vulthil.SharedKernel.Application/Pipeline/PipelineHandlerDecorator.cs b/src/Vulthil.SharedKernel.Application/Pipeline/PipelineHandlerDecorator.cs new file mode 100644 index 0000000..0bda905 --- /dev/null +++ b/src/Vulthil.SharedKernel.Application/Pipeline/PipelineHandlerDecorator.cs @@ -0,0 +1,37 @@ +using Vulthil.SharedKernel.Application.Messaging; + +namespace Vulthil.SharedKernel.Application.Pipeline; + +/// +/// Decorates a registered handler with all +/// behaviors resolved from the service provider at construction time. +/// +/// +/// This is the single object that implements the request pipeline. It is registered against +/// every handler interface a concrete handler implements (, +/// , , +/// ) via lightweight forwarding adapters so that +/// every direct injection and the dispatch path share the same pipeline. +/// +internal sealed class PipelineHandlerDecorator( + IInnerHandler inner, + IEnumerable> behaviors) + : IHandler + where TRequest : IRequest +{ + private readonly IInnerHandler _inner = inner; + private readonly IEnumerable> _behaviors = behaviors; + + /// + public Task HandleAsync(TRequest request, CancellationToken cancellationToken = default) + { + Task Handler(CancellationToken t) => _inner.HandleAsync(request, t); + + var pipeline = _behaviors + .Reverse() + .Aggregate((PipelineDelegate)Handler, + (next, behavior) => t => behavior.HandleAsync(request, next, t)); + + return pipeline(cancellationToken); + } +} diff --git a/src/Vulthil.SharedKernel.Application/PublicAPI.Unshipped.txt b/src/Vulthil.SharedKernel.Application/PublicAPI.Unshipped.txt index ae4a91b..3beafbb 100644 --- a/src/Vulthil.SharedKernel.Application/PublicAPI.Unshipped.txt +++ b/src/Vulthil.SharedKernel.Application/PublicAPI.Unshipped.txt @@ -8,11 +8,15 @@ static Vulthil.SharedKernel.Application.DependencyInjection.AddFluentValidation( static Vulthil.SharedKernel.Application.DependencyInjection.AddHandlers(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! static Vulthil.SharedKernel.Application.DependencyInjection.AddHandlers(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, System.Action! handlerOptionsAction) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! static Vulthil.SharedKernel.Application.DependencyInjection.AddHandlers(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, Vulthil.SharedKernel.Application.HandlerOptions! handlerOptions) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! +static Vulthil.SharedKernel.Application.DependencyInjection.AddOpenPipelineHandler(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, System.Type! pipelineHandler) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! +static Vulthil.SharedKernel.Application.DependencyInjection.AddOpenDomainEventPipelineHandler(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, System.Type! pipelineHandler) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! static Vulthil.SharedKernel.Application.FluentValidationExtensions.WithError(this FluentValidation.IRuleBuilderOptions! rule, Vulthil.Results.Error! error) -> FluentValidation.IRuleBuilderOptions! Vulthil.SharedKernel.Application.ApplicationOptions Vulthil.SharedKernel.Application.ApplicationOptions.AddDomainEventLoggingBehavior() -> Vulthil.SharedKernel.Application.ApplicationOptions! Vulthil.SharedKernel.Application.ApplicationOptions.AddRequestLoggingBehavior() -> Vulthil.SharedKernel.Application.ApplicationOptions! Vulthil.SharedKernel.Application.ApplicationOptions.AddTransactionalPipelineBehavior() -> Vulthil.SharedKernel.Application.ApplicationOptions! +Vulthil.SharedKernel.Application.ApplicationOptions.AddOpenDomainEventPipelineHandler(System.Type! pipelineHandler) -> Vulthil.SharedKernel.Application.ApplicationOptions! +Vulthil.SharedKernel.Application.ApplicationOptions.AddOpenPipelineHandler(System.Type! pipelineHandler) -> Vulthil.SharedKernel.Application.ApplicationOptions! Vulthil.SharedKernel.Application.ApplicationOptions.AddValidationPipelineBehavior() -> Vulthil.SharedKernel.Application.ApplicationOptions! Vulthil.SharedKernel.Application.ApplicationOptions.ApplicationOptions() -> void Vulthil.SharedKernel.Application.ApplicationOptions.FluentValidationAssemblies.get -> System.Collections.Generic.IReadOnlyList! diff --git a/src/Vulthil.SharedKernel.Application/Vulthil.SharedKernel.Application.csproj b/src/Vulthil.SharedKernel.Application/Vulthil.SharedKernel.Application.csproj index cb332e3..cdc870e 100644 --- a/src/Vulthil.SharedKernel.Application/Vulthil.SharedKernel.Application.csproj +++ b/src/Vulthil.SharedKernel.Application/Vulthil.SharedKernel.Application.csproj @@ -11,7 +11,6 @@ - diff --git a/tests/Vulthil.SharedKernel.Application.Tests/Pipeline/HandlerRegistrationTests.cs b/tests/Vulthil.SharedKernel.Application.Tests/Pipeline/HandlerRegistrationTests.cs new file mode 100644 index 0000000..5b081fd --- /dev/null +++ b/tests/Vulthil.SharedKernel.Application.Tests/Pipeline/HandlerRegistrationTests.cs @@ -0,0 +1,206 @@ +using Microsoft.Extensions.DependencyInjection; +using Vulthil.Results; +using Vulthil.SharedKernel.Application.Messaging; +using Vulthil.SharedKernel.Application.Pipeline; +using Vulthil.xUnit; + +namespace Vulthil.SharedKernel.Application.Tests.Pipeline; + +/// +/// Verifies that directly injected handler interfaces resolve to the pipeline-decorated +/// handler and that reuses the same composition. +/// +public sealed class HandlerRegistrationTests : BaseUnitTestCase +{ + [Fact] + public async Task ResolvingIHandlerReturnsPipelineDecoratedHandler() + { + var services = BuildServices(addBehavior: true); + await using var provider = services.BuildServiceProvider(); + using var scope = provider.CreateScope(); + + var handler = scope.ServiceProvider.GetRequiredService>>(); + var result = await handler.HandleAsync(new PingCommand("hi"), CancellationToken); + + result.IsSuccess.ShouldBeTrue(); + result.Value.ShouldBe("[wrapped] hi"); + } + + [Fact] + public async Task ResolvingICommandHandlerWithResponseReturnsPipelineDecoratedHandler() + { + var services = BuildServices(addBehavior: true); + await using var provider = services.BuildServiceProvider(); + using var scope = provider.CreateScope(); + + var handler = scope.ServiceProvider.GetRequiredService>>(); + var result = await handler.HandleAsync(new PingCommand("yo"), CancellationToken); + + result.Value.ShouldBe("[wrapped] yo"); + } + + [Fact] + public async Task ResolvingICommandHandlerUnitVariantReturnsPipelineDecoratedHandler() + { + var services = BuildServices(addBehavior: true); + await using var provider = services.BuildServiceProvider(); + using var scope = provider.CreateScope(); + + var handler = scope.ServiceProvider.GetRequiredService>(); + var result = await handler.HandleAsync(new TickCommand(), CancellationToken); + + // The behavior writes a sentinel into the static probe; success implies the inner ran too. + TestBehavior.WasInvoked.ShouldBeTrue(); + result.IsSuccess.ShouldBeTrue(); + } + + [Fact] + public async Task ResolvingIQueryHandlerReturnsPipelineDecoratedHandler() + { + var services = BuildServices(addBehavior: true); + await using var provider = services.BuildServiceProvider(); + using var scope = provider.CreateScope(); + + var handler = scope.ServiceProvider.GetRequiredService>>(); + var result = await handler.HandleAsync(new EchoQuery("echo"), CancellationToken); + + result.Value.ShouldBe("[wrapped] echo"); + } + + [Fact] + public async Task SenderInvokesPipelineDecoratedHandlerExactlyOnce() + { + var services = BuildServices(addBehavior: true); + await using var provider = services.BuildServiceProvider(); + using var scope = provider.CreateScope(); + + TestBehavior.InvocationCount = 0; + var sender = scope.ServiceProvider.GetRequiredService(); + await sender.SendAsync(new PingCommand("once"), CancellationToken); + + TestBehavior.InvocationCount.ShouldBe(1); + } + + [Fact] + public async Task BehaviorsRegisteredAfterHandlersStillApply() + { + var services = new ServiceCollection(); + services.AddApplication(o => o.RegisterHandlerAssemblies(typeof(HandlerRegistrationTests).Assembly)); + services.AddApplication(o => + { + o.RegisterHandlerAssemblies(typeof(HandlerRegistrationTests).Assembly); + o.AddOpenPipelineHandler(typeof(TestBehavior<,>)); + }); + + await using var provider = services.BuildServiceProvider(); + using var scope = provider.CreateScope(); + + TestBehavior.InvocationCount = 0; + var handler = scope.ServiceProvider.GetRequiredService>>(); + await handler.HandleAsync(new PingCommand("late"), CancellationToken); + + TestBehavior.InvocationCount.ShouldBe(1); + } + + [Fact] + public async Task ConcreteHandlerTypeIsNotResolvableViaDi() + { + var services = BuildServices(addBehavior: false); + await using var provider = services.BuildServiceProvider(); + using var scope = provider.CreateScope(); + + var directlyResolved = scope.ServiceProvider.GetService(typeof(PingCommandHandler)); + + directlyResolved.ShouldBeNull(); + } + + [Fact] + public async Task BehaviorWithUnmatchedConstraintIsSkipped() + { + var services = new ServiceCollection(); + services.AddApplication(o => + { + o.RegisterHandlerAssemblies(typeof(HandlerRegistrationTests).Assembly); + // CommandOnlyBehavior only matches ICommand requests — queries should not pick it up. + o.AddOpenPipelineHandler(typeof(CommandOnlyBehavior<,>)); + }); + + await using var provider = services.BuildServiceProvider(); + using var scope = provider.CreateScope(); + + var handler = scope.ServiceProvider.GetRequiredService>>(); + var result = await handler.HandleAsync(new EchoQuery("plain"), CancellationToken); + + // Behavior did not wrap the response. + result.Value.ShouldBe("plain"); + } + + private static ServiceCollection BuildServices(bool addBehavior) + { + var services = new ServiceCollection(); + services.AddApplication(o => + { + o.RegisterHandlerAssemblies(typeof(HandlerRegistrationTests).Assembly); + if (addBehavior) + { + o.AddOpenPipelineHandler(typeof(TestBehavior<,>)); + } + }); + return services; + } +} + +internal static class TestBehavior +{ + public static int InvocationCount; + public static bool WasInvoked => InvocationCount > 0; +} + +internal sealed class TestBehavior : IPipelineHandler + where TRequest : IRequest +{ + public async Task HandleAsync(TRequest request, PipelineDelegate next, CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref TestBehavior.InvocationCount); + var response = await next(cancellationToken); + + // Decorate string-bearing Result responses so tests can observe the behavior ran. + if (response is Result { IsSuccess: true } stringResult) + { + return (TResponse)(object)Result.Success("[wrapped] " + stringResult.Value); + } + + return response; + } +} + +internal sealed class CommandOnlyBehavior : IPipelineHandler + where TCommand : ICommand +{ + public Task HandleAsync(TCommand request, PipelineDelegate next, CancellationToken cancellationToken = default) => + next(cancellationToken); +} + +public sealed record PingCommand(string Message) : ICommand>; + +internal sealed class PingCommandHandler : ICommandHandler> +{ + public Task> HandleAsync(PingCommand request, CancellationToken cancellationToken = default) => + Task.FromResult(Result.Success(request.Message)); +} + +public sealed record TickCommand : ICommand; + +internal sealed class TickCommandHandler : ICommandHandler +{ + public Task HandleAsync(TickCommand request, CancellationToken cancellationToken = default) => + Task.FromResult(Result.Success()); +} + +public sealed record EchoQuery(string Value) : IQuery>; + +internal sealed class EchoQueryHandler : IQueryHandler> +{ + public Task> HandleAsync(EchoQuery request, CancellationToken cancellationToken = default) => + Task.FromResult(Result.Success(request.Value)); +} From fc554fe84287e0fc118774ebbd68c8c8de4878cb Mon Sep 17 00:00:00 2001 From: Vulthil Date: Mon, 25 May 2026 12:42:41 +0200 Subject: [PATCH 02/42] feat(messaging): harden RabbitMQ transport and add config-driven setup Bring the messaging libraries up to a production-ready baseline with structured logging, observability, health checking, and configuration-driven topology declaration. Consumers gain an in-context PublishAsync that auto-propagates correlation metadata. Public surface (Abstractions): - IMessageContext.PublishAsync(message, configure?) auto-propagates CorrelationId/ConversationId/InitiatorId from the incoming context. - IMessageContext.CancellationToken exposes the delivery's cancellation. Public surface (Vulthil.Messaging): - MessageConfiguration.Exchange is now non-nullable; the new required constructor takes the exchange name. MessageConfiguration defaults it to typeof(T).FullName. - AddMessaging eagerly loads Messaging:Queues:* and Messaging:Messages:* from IConfiguration before the configurator action runs; code calls merge onto the loaded values and win on conflict. Services can now declare topology entirely in appsettings. - IMessageConfigurationProvider.QueueDefinitions exposes the assembled queue set; transports pull queues from there rather than from individual DI singletons. - Removed half-baked IFaultConfigurator / ConfigureFaults / MessagingOptions.AutoDeclareFaultStatus (still unshipped; will be redesigned in a follow-up). Public surface (Vulthil.Messaging.RabbitMq): - MessagingInstrumentation.ActivitySourceName plus the TracerProviderBuilder extension AddVulthilMessagingInstrumentation(). UseRabbitMq registers it automatically; gated on RabbitMQClientSettings.DisableTracing so the Aspire flag toggles the full pipeline. - vulthil_messaging_rabbitmq_bus health check that flips to Healthy after RabbitMqBus.StartAsync completes; gated on DisableHealthChecks. Internal changes: - Producer-only services no longer initialize the reply queue eagerly; ResponseListener is lazy-initialized on the first IRequester call. - RabbitMqBus declares per-message exchanges using MessageConfiguration via IMessageConfigurationProvider, so the bus and publisher use the same source of truth and never disagree on topology. - Worker/publisher/requester gained LoggerMessage-based structured logs; silent catches on fault publish, poison messages, and missing execution plans now surface as warnings/errors. - Fault snapshots embed JsonElement (faithful payload roundtrip) instead of a deserialized object. Docs: - Refreshed docs/articles/messaging.md with sections on in-context publish, message configuration, configuration-driven setup, observability, and health checks. - Pointer additions in the per-package docs for Abstractions, Vulthil.Messaging, and Vulthil.Messaging.RabbitMq. Chore: - Moved documentation-guidelines.instructions.md under .github/instructions/ where the scoped-instructions loader in CLAUDE.md expects it. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../documentation-guidelines.instructions.md | 0 Vulthil.SharedKernel.slnx | 3 +- docs/articles/messaging.md | 186 ++++++++++++++++-- .../vulthil-messaging-abstractions.md | 11 ++ .../packages/vulthil-messaging-rabbitmq.md | 10 + docs/articles/packages/vulthil-messaging.md | 8 + .../PublicAPI.Unshipped.txt | 2 + .../Publishers/IPublisher.cs | 11 ++ .../Publishers/IRequester.cs | 14 ++ .../Consumers/NullPublisher.cs | 7 +- .../Publishing/RabbitMqPublisher.cs | 5 + src/Vulthil.Messaging.RabbitMq/RabbitMqBus.cs | 5 +- .../Requests/RabbitMqRequester.cs | 6 + .../Requests/ResponseListener.cs | 10 +- src/Vulthil.Messaging/DependencyInjection.cs | 45 ++++- .../IMessageConfigurationProvider.cs | 7 + .../MessageConfigurationProvider.cs | 12 +- .../MessagingConfigurator.cs | 69 +++++-- src/Vulthil.Messaging/MessagingOptions.cs | 15 +- src/Vulthil.Messaging/PublicAPI.Unshipped.txt | 1 + .../ConsumerRegistrationTests.cs | 34 ++-- .../MessagingConfiguratiorTests.cs | 90 +++------ 22 files changed, 419 insertions(+), 132 deletions(-) rename documentation-guidelines.instructions.md => .github/instructions/documentation-guidelines.instructions.md (100%) diff --git a/documentation-guidelines.instructions.md b/.github/instructions/documentation-guidelines.instructions.md similarity index 100% rename from documentation-guidelines.instructions.md rename to .github/instructions/documentation-guidelines.instructions.md diff --git a/Vulthil.SharedKernel.slnx b/Vulthil.SharedKernel.slnx index 73d47b7..8f31783 100644 --- a/Vulthil.SharedKernel.slnx +++ b/Vulthil.SharedKernel.slnx @@ -7,8 +7,8 @@ + - @@ -44,6 +44,7 @@ + diff --git a/docs/articles/messaging.md b/docs/articles/messaging.md index b83f79f..efb5061 100644 --- a/docs/articles/messaging.md +++ b/docs/articles/messaging.md @@ -20,7 +20,7 @@ public sealed class OrderCreatedConsumer : IConsumer { public Task ConsumeAsync( IMessageContext messageContext, - CancellationToken cancellationToken) + CancellationToken cancellationToken = default) { var order = messageContext.Message; // Process the event @@ -36,16 +36,21 @@ public sealed class GetOrderConsumer : IRequestConsumer ConsumeAsync( IMessageContext messageContext, - CancellationToken cancellationToken) + CancellationToken cancellationToken = default) { return Task.FromResult(new OrderDto()); } } ``` +The request consumer keeps its strongly-typed `Task` contract — the requester +on the other side will receive a typed `Result`. + ## Registering Queues and Consumers -Registration happens in the composition root using the `AddMessaging` builder: +Registration happens in the composition root using the `AddMessaging` builder. Queue +definitions and message configurations are first loaded eagerly from `IConfiguration`, +then merged with whatever code-side calls add; code wins on conflict. ```csharp builder.AddMessaging(messaging => @@ -80,11 +85,43 @@ public sealed class PlaceOrderHandler(IPublisher publisher) public async Task HandleAsync(PlaceOrderCommand command, CancellationToken ct) { // ... create order ... - await publisher.PublishAsync(new OrderCreatedEvent(order.Id), ct); + await publisher.PublishAsync(new OrderCreatedEvent(order.Id), cancellationToken: ct); } } ``` +### Publishing from inside a consumer + +`IMessageContext` exposes `PublishAsync` directly, so consumers can emit follow-up +messages without injecting `IPublisher`. Correlation metadata +(`CorrelationId`, `ConversationId`, `InitiatorId`) is automatically propagated from +the incoming message to the outgoing one. The optional `configure` callback runs +after auto-propagation, so explicit values override the inherited ones. + +```csharp +public sealed class OrderCreatedConsumer : IConsumer +{ + public async Task ConsumeAsync( + IMessageContext ctx, + CancellationToken cancellationToken = default) + { + // Inherits CorrelationId/ConversationId/InitiatorId from ctx + await ctx.PublishAsync(new InventoryReserveRequested(ctx.Message.OrderId)); + + // Or override specific fields explicitly + await ctx.PublishAsync(new ShippingScheduled(ctx.Message.OrderId), c => + { + c.SetCorrelationId("new-correlation"); + c.AddHeader("priority", "high"); + return ValueTask.CompletedTask; + }); + } +} +``` + +`IMessageContext.CancellationToken` exposes the delivery's cancellation token for +handlers that want to observe it alongside the explicit method parameter. + ## Routing Keys Routing keys control which consumers receive a message on topic exchanges. @@ -106,35 +143,162 @@ messaging.ConfigureMessage(message => }); ``` -## Queue Configuration +## Message Configuration -Queue settings can be tuned in code or bound from `appsettings.json`: +Each message type is associated with a `MessageConfiguration` that controls the +exchange name, exchange type, durability, and routing/correlation formatters used +when publishing. The `Exchange` defaults to the message CLR full type name when +constructed via `MessageConfiguration`; the publisher and bus topology +share that same source of truth, so they never get out of sync. + +```csharp +messaging.ConfigureMessage(m => +{ + m.Exchange = "orders.events"; // override default of typeof().FullName + m.ExchangeType = MessagingExchangeType.Topic; + m.Durable = true; + m.UseRoutingKey(e => $"order.{e.Region}"); +}); +``` + +`MessageConfiguration` instances can also come from configuration — see below. + +## Configuration-driven Setup + +Queue and message settings can be defined entirely in `appsettings.json`. The +`AddMessaging` call loads every section under `Messaging:Queues:*` and +`Messaging:Messages:*` into the runtime before running the configurator action. +Subsequent `ConfigureQueue` / `ConfigureMessage` calls mutate the loaded +instances, with code taking precedence on conflict. ```json { "Messaging": { + "Options": { + "DefaultTimeout": "00:00:30", + "FaultExchangeName": "Fault.Exchange" + }, "Queues": { "order-events": { "PrefetchCount": 64, "ChannelCount": 2, - "ConcurrencyLimit": 4 + "ConcurrencyLimit": 4, + "IsQuorum": true, + "DefaultRetryPolicy": { + "MaxRetryCount": 3, + "JitterFactor": 0.2, + "Intervals": [ "00:00:01", "00:00:05", "00:00:30" ] + } + } + }, + "Messages": { + "Acme.Orders.OrderCreatedEvent": { + "Exchange": "orders.events", + "ExchangeType": "Topic", + "Durable": true } } } } ``` +### Config-only setup + +A service can declare its topology purely in `appsettings.json` and skip the code +side entirely — useful for publisher-only services or environments where queue +shape is owned by ops: + +```csharp +builder.AddMessaging(m => m.UseRabbitMq()); +``` + +### Code-only override + +Code values always win over configuration values: + ```csharp -queue.ConfigureQueue(q => +builder.AddMessaging(m => { - q.PrefetchCount = 32; - q.ExchangeType = MessagingExchangeType.Topic; + m.UseRabbitMq(); + m.ConfigureQueue("order-events", q => + { + q.ConfigureQueue(d => d.PrefetchCount = 128); // overrides appsettings value + q.AddConsumer(); + }); }); ``` +### Merged + +The common case — topology from config, consumer wiring from code: + +```json +{ "Messaging": { "Queues": { "order-events": { "PrefetchCount": 64 } } } } +``` + +```csharp +m.ConfigureQueue("order-events", q => q.AddConsumer()); +// PrefetchCount=64 (from config) + OrderCreatedConsumer registration (from code) +``` + +## Observability + +The RabbitMQ transport emits an `ActivitySource` named `"Vulthil.Messaging.RabbitMq"` +with `Producer`/`Consumer` spans for publish, request, and receive operations. Tag +conventions follow the OpenTelemetry messaging semantic conventions, with a few +Vulthil-specific tags (`vulthil.messaging.message_type`, `.consumer_type`, +`.retry_count`, `.queue`). + +`UseRabbitMq` registers the source with the application's `TracerProvider` +automatically, gated on the Aspire client's `DisableTracing` setting — so disabling +RabbitMQ tracing in Aspire suppresses the Vulthil spans too. If you bring your own +`TracerProvider` configuration, you can register the source manually: + +```csharp +builder.Services.AddOpenTelemetry() + .WithTracing(tracing => tracing.AddVulthilMessagingInstrumentation()); +``` + +W3C trace context (`traceparent` / `tracestate`) propagation is handled by +`RabbitMQ.Client` itself, so producer-side activities link to consumer-side +activities on the receiving service without any extra setup. + +## Health Checks + +`UseRabbitMq` also registers a startup health check named +`"vulthil_messaging_rabbitmq_bus"` (tagged `ready`, `messaging`, `rabbitmq`). It +reports: + +- `Unhealthy("starting")` until `RabbitMqBus.StartAsync` completes (topology + declaration + consumer registration finished). +- `Healthy("started")` after a successful startup. +- `Unhealthy(...)` with the original exception if startup fails. + +Registration is gated on the Aspire client's `DisableHealthChecks` setting; set +that to `true` to suppress the health check alongside Aspire's connection-level +health check. + +## Request/Reply + +`IRequester` is registered automatically by `UseRabbitMq` and returns a typed +`Result`: + +```csharp +public sealed class OrderLookupService(IRequester requester) +{ + public Task> GetAsync(Guid orderId, CancellationToken ct) + => requester.RequestAsync( + new GetOrderRequest(orderId), cancellationToken: ct); +} +``` + +The reply queue is created lazily on the first request, so producer-only services +that never call `RequestAsync` do not declare any reply infrastructure. + ## Testing Messaging -`Vulthil.Messaging.TestHarness` provides an in-memory transport that captures published messages for assertion: +`Vulthil.Messaging.TestHarness` provides an in-memory transport that captures +published messages for assertion: ```csharp var published = testHarness.Published(); diff --git a/docs/articles/packages/vulthil-messaging-abstractions.md b/docs/articles/packages/vulthil-messaging-abstractions.md index 76e72f0..2833cb4 100644 --- a/docs/articles/packages/vulthil-messaging-abstractions.md +++ b/docs/articles/packages/vulthil-messaging-abstractions.md @@ -58,3 +58,14 @@ await publisher.PublishAsync(new OrderCreatedEvent(orderId), cancellationToken: Result result = await requester.RequestAsync( new GetOrderRequest(orderId), cancellationToken: ct); ``` + +### Publishing from a consumer + +`IMessageContext` exposes `PublishAsync` with automatic correlation propagation +(`CorrelationId`, `ConversationId`, `InitiatorId` flow from the incoming context): + +```csharp +await ctx.PublishAsync(new InventoryReserveRequested(ctx.Message.OrderId)); +``` + +See [Messaging](../messaging.md#publishing-from-inside-a-consumer) for details. diff --git a/docs/articles/packages/vulthil-messaging-rabbitmq.md b/docs/articles/packages/vulthil-messaging-rabbitmq.md index 3a758cc..5ae06b6 100644 --- a/docs/articles/packages/vulthil-messaging-rabbitmq.md +++ b/docs/articles/packages/vulthil-messaging-rabbitmq.md @@ -51,3 +51,13 @@ Queue settings can be bound from `appsettings.json` under `Messaging:Queues:{nam } } ``` + +### Tracing and health checks + +`UseRabbitMq` registers an OpenTelemetry `ActivitySource` +(`"Vulthil.Messaging.RabbitMq"`) and a startup health check +(`"vulthil_messaging_rabbitmq_bus"`). Both registrations are gated on the Aspire +client's `DisableTracing` / `DisableHealthChecks` flags, so the toggles propagate +through to the Vulthil instrumentation. See +[Messaging — Observability](../messaging.md#observability) and +[Messaging — Health Checks](../messaging.md#health-checks). diff --git a/docs/articles/packages/vulthil-messaging.md b/docs/articles/packages/vulthil-messaging.md index 2190970..854979d 100644 --- a/docs/articles/packages/vulthil-messaging.md +++ b/docs/articles/packages/vulthil-messaging.md @@ -56,3 +56,11 @@ queue.AddConsumer(c => c.Bind("order.eu"); }); ``` + +### Configuration-driven setup + +Queue and message settings under `Messaging:Queues:*` and `Messaging:Messages:*` +are loaded from `IConfiguration` before the configurator action runs, so a service +can be configured entirely via `appsettings.json`. Code calls merge on top of the +loaded values, with code winning on conflict. See +[Messaging — Configuration-driven Setup](../messaging.md#configuration-driven-setup). diff --git a/src/Vulthil.Messaging.Abstractions/PublicAPI.Unshipped.txt b/src/Vulthil.Messaging.Abstractions/PublicAPI.Unshipped.txt index 17d9465..954d07f 100644 --- a/src/Vulthil.Messaging.Abstractions/PublicAPI.Unshipped.txt +++ b/src/Vulthil.Messaging.Abstractions/PublicAPI.Unshipped.txt @@ -58,5 +58,7 @@ Vulthil.Messaging.Abstractions.Publishers.IPublishContext.SetCorrelationId(strin Vulthil.Messaging.Abstractions.Publishers.IPublishContext.SetRoutingKey(string! routingKey) -> void Vulthil.Messaging.Abstractions.Publishers.IPublisher Vulthil.Messaging.Abstractions.Publishers.IPublisher.PublishAsync(TMessage message, System.Func? configureContext = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +Vulthil.Messaging.Abstractions.Publishers.IPublisher.PublishAsync(TMessage message, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! Vulthil.Messaging.Abstractions.Publishers.IRequester Vulthil.Messaging.Abstractions.Publishers.IRequester.RequestAsync(TRequest message, System.Func? configureContext = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +Vulthil.Messaging.Abstractions.Publishers.IRequester.RequestAsync(TRequest message, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task!>! diff --git a/src/Vulthil.Messaging.Abstractions/Publishers/IPublisher.cs b/src/Vulthil.Messaging.Abstractions/Publishers/IPublisher.cs index 0a79f2f..a3e4cf9 100644 --- a/src/Vulthil.Messaging.Abstractions/Publishers/IPublisher.cs +++ b/src/Vulthil.Messaging.Abstractions/Publishers/IPublisher.cs @@ -5,6 +5,17 @@ namespace Vulthil.Messaging.Abstractions.Publishers; /// public interface IPublisher { + /// + /// Publishes a message to the broker with optional context configuration. + /// + /// The type of message to publish. + /// The message to publish. + /// A token to observe for cancellation. + Task PublishAsync( + TMessage message, + CancellationToken cancellationToken) + where TMessage : notnull; + /// /// Publishes a message to the broker with optional context configuration. /// diff --git a/src/Vulthil.Messaging.Abstractions/Publishers/IRequester.cs b/src/Vulthil.Messaging.Abstractions/Publishers/IRequester.cs index c70d00d..a03c671 100644 --- a/src/Vulthil.Messaging.Abstractions/Publishers/IRequester.cs +++ b/src/Vulthil.Messaging.Abstractions/Publishers/IRequester.cs @@ -7,6 +7,20 @@ namespace Vulthil.Messaging.Abstractions.Publishers; /// public interface IRequester { + /// + /// Sends a request and awaits a response, returning the outcome as a . + /// + /// The request message type. + /// The expected response type. + /// The request message to send. + /// A token to observe for cancellation. + /// A containing the response on success or an error on failure. + Task> RequestAsync( + TRequest message, + CancellationToken cancellationToken) + where TRequest : notnull + where TResponse : notnull; + /// /// Sends a request and awaits a response, returning the outcome as a . /// diff --git a/src/Vulthil.Messaging.RabbitMq/Consumers/NullPublisher.cs b/src/Vulthil.Messaging.RabbitMq/Consumers/NullPublisher.cs index 5daf104..d634d4b 100644 --- a/src/Vulthil.Messaging.RabbitMq/Consumers/NullPublisher.cs +++ b/src/Vulthil.Messaging.RabbitMq/Consumers/NullPublisher.cs @@ -5,14 +5,17 @@ namespace Vulthil.Messaging.RabbitMq.Consumers; /// /// A placeholder used by snapshots /// (e.g. ) where no live -/// transport publisher is bound. Calling on a snapshot is a programmer error. +/// transport publisher is bound. Calling or on a snapshot is a programmer error. /// internal sealed class NullPublisher : IPublisher { public static readonly NullPublisher Instance = new(); private NullPublisher() { } - + public Task PublishAsync( + TMessage message, + CancellationToken cancellationToken) + where TMessage : notnull => PublishAsync(message, null, cancellationToken); public Task PublishAsync( TMessage message, Func? configureContext = null, diff --git a/src/Vulthil.Messaging.RabbitMq/Publishing/RabbitMqPublisher.cs b/src/Vulthil.Messaging.RabbitMq/Publishing/RabbitMqPublisher.cs index 5bc534b..b7f55c9 100644 --- a/src/Vulthil.Messaging.RabbitMq/Publishing/RabbitMqPublisher.cs +++ b/src/Vulthil.Messaging.RabbitMq/Publishing/RabbitMqPublisher.cs @@ -61,6 +61,11 @@ public async Task InternalPublishAsync( } } + public Task PublishAsync( + TMessage message, + CancellationToken cancellationToken) + where TMessage : notnull => PublishAsync(message, null, cancellationToken); + public async Task PublishAsync( TMessage message, Func? configureContext = null, diff --git a/src/Vulthil.Messaging.RabbitMq/RabbitMqBus.cs b/src/Vulthil.Messaging.RabbitMq/RabbitMqBus.cs index 1cbef6d..96f63fd 100644 --- a/src/Vulthil.Messaging.RabbitMq/RabbitMqBus.cs +++ b/src/Vulthil.Messaging.RabbitMq/RabbitMqBus.cs @@ -12,7 +12,6 @@ internal sealed class RabbitMqBus : ITransport, IAsyncDisposable { private readonly IServiceScopeFactory _serviceScopeFactory; private readonly IConnection _connection; - private readonly IEnumerable _queueDefinitions; private readonly IMessageConfigurationProvider _messageConfigurationProvider; private readonly RabbitMqBusStartupStatus _startupStatus; private readonly ILogger _logger; @@ -23,7 +22,6 @@ internal sealed class RabbitMqBus : ITransport, IAsyncDisposable public RabbitMqBus( IServiceScopeFactory serviceScopeFactory, IConnection connection, - IEnumerable queueDefinitions, IMessageConfigurationProvider messageConfigurationProvider, RabbitMqBusStartupStatus startupStatus, ILogger logger, @@ -31,7 +29,6 @@ public RabbitMqBus( { _serviceScopeFactory = serviceScopeFactory; _connection = connection; - _queueDefinitions = queueDefinitions; _messageConfigurationProvider = messageConfigurationProvider; _startupStatus = startupStatus; _logger = logger; @@ -42,7 +39,7 @@ public async Task StartAsync(CancellationToken cancellationToken = default) { try { - var queues = _queueDefinitions.ToList(); + var queues = _messageConfigurationProvider.QueueDefinitions; MessagingLog.BusStarting(_logger, queues.Count); await SetupTopology(queues, cancellationToken); diff --git a/src/Vulthil.Messaging.RabbitMq/Requests/RabbitMqRequester.cs b/src/Vulthil.Messaging.RabbitMq/Requests/RabbitMqRequester.cs index bfe62dc..d2aa29d 100644 --- a/src/Vulthil.Messaging.RabbitMq/Requests/RabbitMqRequester.cs +++ b/src/Vulthil.Messaging.RabbitMq/Requests/RabbitMqRequester.cs @@ -33,6 +33,12 @@ public RabbitMqRequester( private JsonSerializerOptions JsonOptions => _messageConfigurationProvider.JsonSerializerOptions; private TimeSpan DefaultTimeout => _messageConfigurationProvider.DefaultTimeout; + public Task> RequestAsync( + TRequest message, + CancellationToken cancellationToken) + where TRequest : notnull + where TResponse : notnull => RequestAsync(message, null, cancellationToken); + public async Task> RequestAsync( TRequest message, Func? configureContext = null, diff --git a/src/Vulthil.Messaging.RabbitMq/Requests/ResponseListener.cs b/src/Vulthil.Messaging.RabbitMq/Requests/ResponseListener.cs index d8c8fdb..3494e8e 100644 --- a/src/Vulthil.Messaging.RabbitMq/Requests/ResponseListener.cs +++ b/src/Vulthil.Messaging.RabbitMq/Requests/ResponseListener.cs @@ -1,7 +1,6 @@ using System.Collections.Concurrent; using System.Text.Json; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; using RabbitMQ.Client; using RabbitMQ.Client.Events; using Vulthil.Messaging.RabbitMq.Logging; @@ -12,22 +11,23 @@ namespace Vulthil.Messaging.RabbitMq.Requests; internal sealed class ResponseListener : IAsyncDisposable { private readonly IConnection _connection; + private readonly IMessageConfigurationProvider _messageConfigurationProvider; private readonly ILogger _logger; private readonly ConcurrentDictionary _waiters = new(); - private readonly JsonSerializerOptions _jsonOptions; private readonly SemaphoreSlim _initLock = new(1, 1); + private JsonSerializerOptions JsonOptions => _messageConfigurationProvider.JsonSerializerOptions; private IChannel? _channel; private string _replyToQueueName = string.Empty; public ResponseListener( IConnection connection, - IOptions messagingOptions, + IMessageConfigurationProvider messageConfigurationProvider, ILogger logger) { _connection = connection; + _messageConfigurationProvider = messageConfigurationProvider; _logger = logger; - _jsonOptions = messagingOptions.Value.JsonSerializerOptions; } /// @@ -46,7 +46,7 @@ public async ValueTask GetReplyToQueueNameAsync(CancellationToken cancel } public void RegisterWaiter(string correlationId, TaskCompletionSource> tcs) where T : notnull - => _waiters[correlationId] = new ResponseWaiter(tcs, _jsonOptions); + => _waiters[correlationId] = new ResponseWaiter(tcs, JsonOptions); public void RemoveWaiter(string correlationId) => _waiters.TryRemove(correlationId, out _); diff --git a/src/Vulthil.Messaging/DependencyInjection.cs b/src/Vulthil.Messaging/DependencyInjection.cs index 202d9b7..6616887 100644 --- a/src/Vulthil.Messaging/DependencyInjection.cs +++ b/src/Vulthil.Messaging/DependencyInjection.cs @@ -1,7 +1,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Options; +using Vulthil.Messaging.Queues; namespace Vulthil.Messaging; @@ -13,6 +13,11 @@ public static class DependencyInjection /// /// Registers messaging services, queues, and consumers with the host application builder. /// + /// + /// Queue definitions and message configurations under Messaging:Queues:* and Messaging:Messages:* + /// are loaded from before runs. + /// Code registrations performed inside the configurator action merge onto the loaded values and take precedence. + /// /// The host application builder. /// An action to configure messaging through . /// The service collection for chaining. @@ -21,14 +26,48 @@ public static IServiceCollection AddMessaging(this IHostApplicationBuilder build var messagingOptions = new MessagingOptions(); builder.Configuration.GetSection(MessagingOptions.SectionName).Bind(messagingOptions); + LoadQueueDefinitionsFromConfiguration(builder.Configuration, messagingOptions); + LoadMessageConfigurationsFromConfiguration(builder.Configuration, messagingOptions); + builder.Services.AddHostedService(); var messagingConfigurator = new MessagingConfigurator(builder, messagingOptions); messagingConfiguratorAction(messagingConfigurator); - builder.Services.AddSingleton(Options.Create(messagingOptions)); - return builder.Services; } + private static void LoadQueueDefinitionsFromConfiguration(IConfiguration configuration, MessagingOptions options) + { + var queuesSection = configuration.GetSection($"{MessagingConfigurator.DefaultSectionName}:Queues"); + foreach (var queueSection in queuesSection.GetChildren()) + { + var name = queueSection.Key; + if (string.IsNullOrWhiteSpace(name)) + { + continue; + } + + var queueDefinition = new QueueDefinition(name); + queueSection.Bind(queueDefinition); + options.QueueDefinitions[name] = queueDefinition; + } + } + + private static void LoadMessageConfigurationsFromConfiguration(IConfiguration configuration, MessagingOptions options) + { + var messagesSection = configuration.GetSection($"{MessagingConfigurator.DefaultSectionName}:Messages"); + foreach (var messageSection in messagesSection.GetChildren()) + { + var fullName = messageSection.Key; + if (string.IsNullOrWhiteSpace(fullName)) + { + continue; + } + + var messageConfiguration = new MessageConfiguration(fullName); + messageSection.Bind(messageConfiguration); + options.MessageConfigurations[fullName] = messageConfiguration; + } + } } diff --git a/src/Vulthil.Messaging/IMessageConfigurationProvider.cs b/src/Vulthil.Messaging/IMessageConfigurationProvider.cs index 9d29db4..a6d6193 100644 --- a/src/Vulthil.Messaging/IMessageConfigurationProvider.cs +++ b/src/Vulthil.Messaging/IMessageConfigurationProvider.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using Vulthil.Messaging.Queues; namespace Vulthil.Messaging; @@ -35,4 +36,10 @@ public interface IMessageConfigurationProvider /// Gets the name of the exchange used for fault messages. /// string FaultExchangeName { get; } + + /// + /// Gets the queue definitions that were assembled from configuration and code at AddMessaging time. + /// Returned as a snapshot; subsequent mutations to the underlying store are not reflected. + /// + IReadOnlyCollection QueueDefinitions { get; } } diff --git a/src/Vulthil.Messaging/MessageConfigurationProvider.cs b/src/Vulthil.Messaging/MessageConfigurationProvider.cs index 9209390..ca27dae 100644 --- a/src/Vulthil.Messaging/MessageConfigurationProvider.cs +++ b/src/Vulthil.Messaging/MessageConfigurationProvider.cs @@ -1,15 +1,11 @@ using System.Text.Json; +using Vulthil.Messaging.Queues; namespace Vulthil.Messaging; -internal sealed class MessageConfigurationProvider : IMessageConfigurationProvider +internal sealed class MessageConfigurationProvider(MessagingOptions options) : IMessageConfigurationProvider { - private readonly MessagingOptions _options; - - public MessageConfigurationProvider(MessagingOptions options) - { - _options = options; - } + private readonly MessagingOptions _options = options; public MessageConfiguration GetMessageConfiguration(Type messageType) => _options.GetMessageConfiguration(messageType); @@ -20,4 +16,6 @@ public MessageConfiguration GetMessageConfiguration() where TMessage : public JsonSerializerOptions JsonSerializerOptions => _options.JsonSerializerOptions; public TimeSpan DefaultTimeout => _options.DefaultTimeout; public string FaultExchangeName => _options.FaultExchangeName; + + public IReadOnlyCollection QueueDefinitions => _options.QueueDefinitions.Values; } diff --git a/src/Vulthil.Messaging/MessagingConfigurator.cs b/src/Vulthil.Messaging/MessagingConfigurator.cs index fd05464..30989fd 100644 --- a/src/Vulthil.Messaging/MessagingConfigurator.cs +++ b/src/Vulthil.Messaging/MessagingConfigurator.cs @@ -7,23 +7,21 @@ namespace Vulthil.Messaging; internal sealed class MessagingConfigurator : IMessagingConfigurator { - private const string DefaultSectionName = "Messaging"; - - private readonly HashSet _queues = []; + internal const string DefaultSectionName = "Messaging"; private readonly MessagingOptions _messagingOptions; /// public IHostApplicationBuilder HostApplicationBuilder { get; } - private IServiceCollection _services => HostApplicationBuilder.Services; - private IConfiguration _configuration => HostApplicationBuilder.Configuration; + private IServiceCollection Services => HostApplicationBuilder.Services; + private IConfiguration Configuration => HostApplicationBuilder.Configuration; /// public MessagingConfigurator(IHostApplicationBuilder hostApplicationBuilder, MessagingOptions messagingOptions) { HostApplicationBuilder = hostApplicationBuilder; _messagingOptions = messagingOptions; - _services.AddSingleton(_ => new MessageConfigurationProvider(_messagingOptions)); + Services.AddSingleton(_ => new MessageConfigurationProvider(_messagingOptions)); } public IMessagingConfigurator ConfigureMessagingOptions(Action action) @@ -32,33 +30,70 @@ public IMessagingConfigurator ConfigureMessagingOptions(Action return this; } - private static string ConstructQueueSectionName(string queueName) => $"{DefaultSectionName}:Queues:{queueName}"; + internal static string ConstructQueueSectionName(string queueName) => $"{DefaultSectionName}:Queues:{queueName}"; public IMessagingConfigurator ConfigureQueue(string queueName, Action queueConfigurationAction) { ArgumentException.ThrowIfNullOrWhiteSpace(queueName); - var queueDefinition = new QueueDefinition(queueName); - _configuration.GetSection(ConstructQueueSectionName(queueName)).Bind(queueDefinition); - var queueConfigurator = new QueueConfigurator(_services, _messagingOptions, queueDefinition); + if (!_messagingOptions.QueueDefinitions.TryGetValue(queueName, out var queueDefinition)) + { + queueDefinition = new QueueDefinition(queueName); + Configuration.GetSection(ConstructQueueSectionName(queueName)).Bind(queueDefinition); + _messagingOptions.QueueDefinitions[queueName] = queueDefinition; + } + + var queueConfigurator = new QueueConfigurator(Services, _messagingOptions, queueDefinition); queueConfigurationAction(queueConfigurator); - _queues.Add(queueDefinition); - _services.AddSingleton(queueDefinition); return this; } - private static string ConstructMessageSectionName() => $"{DefaultSectionName}:Messages:{typeof(TMessage).FullName}"; + internal static string ConstructMessageSectionName(string fullName) => $"{DefaultSectionName}:Messages:{fullName}"; public IMessagingConfigurator ConfigureMessage(Action> configureMessageAction) where TMessage : class { - var messageConfiguration = new MessageConfiguration(); - _configuration.GetSection(ConstructMessageSectionName()).Bind(messageConfiguration); + var fullName = typeof(TMessage).FullName + ?? throw new InvalidOperationException($"Cannot derive a message configuration key for type '{typeof(TMessage)}'."); + + MessageConfiguration typed; - configureMessageAction(messageConfiguration); - _messagingOptions.MessageConfigurations[typeof(TMessage)] = messageConfiguration; + if (_messagingOptions.MessageConfigurations.TryGetValue(fullName, out var existing)) + { + if (existing is MessageConfiguration alreadyTyped) + { + typed = alreadyTyped; + } + else + { + typed = new MessageConfiguration(); + CopyMessageConfiguration(existing, typed); + _messagingOptions.MessageConfigurations[fullName] = typed; + } + } + else + { + typed = new MessageConfiguration(); + Configuration.GetSection(ConstructMessageSectionName(fullName)).Bind(typed); + _messagingOptions.MessageConfigurations[fullName] = typed; + } + configureMessageAction(typed); return this; } + + private static void CopyMessageConfiguration(MessageConfiguration src, MessageConfiguration dst) + { + dst.Exchange = src.Exchange; + dst.ExchangeType = src.ExchangeType; + dst.Durable = src.Durable; + dst.AutoDelete = src.AutoDelete; + foreach (var kvp in src.Arguments) + { + dst.Arguments[kvp.Key] = kvp.Value; + } + dst.RoutingKeyFormatter = src.RoutingKeyFormatter; + dst.CorrelationIdFormatter = src.CorrelationIdFormatter; + } } diff --git a/src/Vulthil.Messaging/MessagingOptions.cs b/src/Vulthil.Messaging/MessagingOptions.cs index 1b6bc07..eb13cce 100644 --- a/src/Vulthil.Messaging/MessagingOptions.cs +++ b/src/Vulthil.Messaging/MessagingOptions.cs @@ -29,14 +29,24 @@ public sealed class MessagingOptions /// public string FaultExchangeName { get; set; } = "Fault.Exchange"; - internal Dictionary MessageConfigurations { get; } = []; + /// + /// Message configurations keyed by the CLR full type name. Populated eagerly from Messaging:Messages:* + /// at AddMessaging time, then merged with whatever ConfigureMessage<T> registers in code. + /// + internal Dictionary MessageConfigurations { get; } = new(StringComparer.Ordinal); + + /// + /// Queue definitions keyed by queue name. Populated eagerly from Messaging:Queues:* at AddMessaging + /// time, then merged with whatever ConfigureQueue registers in code. + /// + internal Dictionary QueueDefinitions { get; } = new(StringComparer.OrdinalIgnoreCase); internal MessageConfiguration GetMessageConfiguration(Type messageType) { var current = messageType; while (current != null && current != typeof(object)) { - if (MessageConfigurations.TryGetValue(current, out var def)) + if (current.FullName is { } fullName && MessageConfigurations.TryGetValue(fullName, out var def)) { return def; } @@ -51,5 +61,4 @@ internal MessageConfiguration GetMessageConfiguration() => GetMessageConfiguration(typeof(TMessage)); internal bool RegisterRequestType(MessageType messageType) => _registeredRequestTypes.Add(messageType); - } diff --git a/src/Vulthil.Messaging/PublicAPI.Unshipped.txt b/src/Vulthil.Messaging/PublicAPI.Unshipped.txt index 98294a0..f5036c7 100644 --- a/src/Vulthil.Messaging/PublicAPI.Unshipped.txt +++ b/src/Vulthil.Messaging/PublicAPI.Unshipped.txt @@ -149,4 +149,5 @@ Vulthil.Messaging.Queues.RetryPolicyDefinition.MaxRetryCount.get -> int Vulthil.Messaging.IMessageConfigurationProvider Vulthil.Messaging.IMessageConfigurationProvider.JsonSerializerOptions.get -> System.Text.Json.JsonSerializerOptions! Vulthil.Messaging.IMessageConfigurationProvider.DefaultTimeout.get -> System.TimeSpan +Vulthil.Messaging.IMessageConfigurationProvider.QueueDefinitions.get -> System.Collections.Generic.IReadOnlyCollection! Vulthil.Messaging.Queues.RetryPolicyDefinition.MaxRetryCount.set -> void diff --git a/tests/Vulthil.Messaging.Tests/ConsumerRegistrationTests.cs b/tests/Vulthil.Messaging.Tests/ConsumerRegistrationTests.cs index d94c1f4..9f69fe0 100644 --- a/tests/Vulthil.Messaging.Tests/ConsumerRegistrationTests.cs +++ b/tests/Vulthil.Messaging.Tests/ConsumerRegistrationTests.cs @@ -1,3 +1,4 @@ +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Vulthil.Messaging.Abstractions.Consumers; using Vulthil.Messaging.Queues; @@ -15,6 +16,12 @@ private static HostApplicationBuilder CreateHostBuilder() return Host.CreateApplicationBuilder(); } + private static IReadOnlyCollection GetQueueDefinitions(HostApplicationBuilder builder) + { + using var sp = builder.Services.BuildServiceProvider(); + return [.. sp.GetRequiredService().QueueDefinitions]; + } + /// /// Executes this member. /// @@ -85,9 +92,8 @@ public void AddConsumerShouldAddConsumerRegistrationToQueueDefinition() }); // Assert - var queueServices = builder.Services.Where(sd => sd.ServiceType == typeof(QueueDefinition)).ToList(); - var queue = queueServices[0].ImplementationInstance.ShouldBeOfType(); - queue.ShouldNotBeNull(); + var queues = GetQueueDefinitions(builder); + var queue = queues.First(); queue.Name.ShouldBe(queueName); queue.Registrations.ShouldNotBeEmpty(); queue.Registrations.First().ConsumerType.Type.ShouldBe(typeof(TestMessageConsumer)); @@ -118,9 +124,7 @@ public void AddConsumerWithRoutingKeyShouldUseCustomRoutingKey() }); // Assert - var queueServices = builder.Services.Where(sd => sd.ServiceType == typeof(QueueDefinition)).ToList(); - var queue = queueServices[0].ImplementationInstance.ShouldBeOfType(); - queue.ShouldNotBeNull(); + var queue = GetQueueDefinitions(builder).First(); queue.Registrations.First().RoutingKey.ShouldBe(customRoutingKey); } @@ -144,9 +148,7 @@ public void AddConsumerWithoutRoutingKeyBindingShouldUseDefaultWildcard() }); // Assert - var queueServices = builder.Services.Where(sd => sd.ServiceType == typeof(QueueDefinition)).ToList(); - var queue = queueServices[0].ImplementationInstance.ShouldBeOfType(); - queue.ShouldNotBeNull(); + var queue = GetQueueDefinitions(builder).First(); queue.Registrations.First().RoutingKey.ShouldBe("#"); } @@ -171,9 +173,7 @@ public void AddMultipleConsumersToSameQueueShouldRegisterAll() }); // Assert - var queueServices = builder.Services.Where(sd => sd.ServiceType == typeof(QueueDefinition)).ToList(); - var queue = queueServices[0].ImplementationInstance.ShouldBeOfType(); - queue.ShouldNotBeNull(); + var queue = GetQueueDefinitions(builder).First(); queue.Registrations.Count.ShouldBe(2); var types = queue.Registrations.Select(r => r.ConsumerType.Type).ToList(); types.Contains(typeof(TestMessageConsumer)).ShouldBeTrue(); @@ -209,15 +209,13 @@ public void SameConsumerInMultipleQueuesWithDifferentRoutingKeysShouldRegisterBo }); // Assert - var queueServices = builder.Services.Where(sd => sd.ServiceType == typeof(QueueDefinition)).ToList(); - queueServices.Count.ShouldBe(2); + var queues = GetQueueDefinitions(builder); + queues.Count.ShouldBe(2); - var queue1 = queueServices.FirstOrDefault(q => q.ImplementationInstance is QueueDefinition { Name: "Queue1" })?.ImplementationInstance.ShouldBeOfType(); - queue1.ShouldNotBeNull(); + var queue1 = queues.First(q => q.Name == "Queue1"); queue1.Registrations.First().RoutingKey.ShouldBe("route1"); - var queue2 = queueServices.FirstOrDefault(q => q.ImplementationInstance is QueueDefinition { Name: "Queue2" })?.ImplementationInstance.ShouldBeOfType(); - queue2.ShouldNotBeNull(); + var queue2 = queues.First(q => q.Name == "Queue2"); queue2.Registrations.First().RoutingKey.ShouldBe("route2"); } diff --git a/tests/Vulthil.Messaging.Tests/MessagingConfiguratiorTests.cs b/tests/Vulthil.Messaging.Tests/MessagingConfiguratiorTests.cs index a5474fe..0f13f1a 100644 --- a/tests/Vulthil.Messaging.Tests/MessagingConfiguratiorTests.cs +++ b/tests/Vulthil.Messaging.Tests/MessagingConfiguratiorTests.cs @@ -1,6 +1,5 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Options; using Vulthil.Messaging.Queues; using Vulthil.xUnit; @@ -9,46 +8,41 @@ namespace Vulthil.Messaging.Tests; /// /// Represents the MessagingConfiguratiorTests. /// -public sealed class MessagingConfiguratiorTests : BaseUnitTestCase +public sealed class MessagingConfiguratiorTests : BaseUnitTestCase { - private static HostApplicationBuilder CreateHostBuilder() + protected override HostApplicationBuilder CreateInstance() => Host.CreateApplicationBuilder(); + + private static IReadOnlyCollection GetQueueDefinitions(HostApplicationBuilder builder) { - return Host.CreateApplicationBuilder(); + using var sp = builder.Services.BuildServiceProvider(); + return [.. sp.GetRequiredService().QueueDefinitions]; } - /// - /// Executes this member. - /// [Fact] public void AddMessagingShouldRegisterConsumerHostedService() { // Arrange - var builder = CreateHostBuilder(); // Act - builder.AddMessaging(x => { }); + Target.AddMessaging(x => { }); // Assert - var hostedServices = builder.Services.Where(sd => sd.ImplementationType == typeof(ConsumerHostedService)).ToList(); + var hostedServices = Target.Services.Where(sd => sd.ImplementationType == typeof(ConsumerHostedService)).ToList(); hostedServices.ShouldNotBeEmpty(); hostedServices[0].Lifetime.ShouldBe(ServiceLifetime.Singleton); } - /// - /// Executes this member. - /// [Fact] - public void AddMessagingShouldRegisterMessagingOptions() + public void AddMessagingShouldRegisterMessageConfigurationProvider() { // Arrange - var builder = CreateHostBuilder(); // Act - builder.AddMessaging(x => { }); + Target.AddMessaging(x => { }); // Assert - var optionsServices = builder.Services.Where(sd => sd.ServiceType == typeof(IOptions)).ToList(); - optionsServices.ShouldNotBeEmpty(); + var messageConfigurationProviderService = Target.Services.Where(sd => sd.ServiceType == typeof(IMessageConfigurationProvider)).ToList(); + messageConfigurationProviderService.ShouldNotBeEmpty(); } /// @@ -58,70 +52,56 @@ public void AddMessagingShouldRegisterMessagingOptions() public void AddMessagingQueueShouldRegisterQueueDefinition() { // Arrange - var builder = CreateHostBuilder(); var queueName = "TestQueue"; // Act - builder.AddMessaging(x => + Target.AddMessaging(x => { x.ConfigureQueue(queueName, _ => { }); }); // Assert - var queueServices = builder.Services.Where(sd => sd.ServiceType == typeof(QueueDefinition)).ToList(); - queueServices.ShouldNotBeEmpty(); - queueServices[0].ImplementationInstance.ShouldBeOfType(); + var queues = GetQueueDefinitions(Target); + queues.ShouldNotBeEmpty(); } - /// - /// Executes this member. - /// [Fact] public void AddMessagingShouldThrowWhenQueueNameIsNull() { // Arrange - var builder = CreateHostBuilder(); // Act & Assert Should.Throw(() => { - builder.AddMessaging(x => + Target.AddMessaging(x => { x.ConfigureQueue(null!, _ => { }); }); }); } - /// - /// Executes this member. - /// [Fact] public void AddMessagingShouldThrowWhenQueueNameIsEmpty() { // Arrange - var builder = CreateHostBuilder(); // Act & Assert Should.Throw(() => { - builder.AddMessaging(x => + Target.AddMessaging(x => { x.ConfigureQueue(string.Empty, _ => { }); }); }); } - /// - /// Executes this member. - /// [Fact] public void AddMessagingMultipleQueuesShouldRegisterAll() { // Arrange - var builder = CreateHostBuilder(); // Act - builder.AddMessaging(x => + Target.AddMessaging(x => { x.ConfigureQueue("Queue1", _ => { }); x.ConfigureQueue("Queue2", _ => { }); @@ -129,13 +109,10 @@ public void AddMessagingMultipleQueuesShouldRegisterAll() }); // Assert - var queueServices = builder.Services.Where(sd => sd.ServiceType == typeof(QueueDefinition)).ToList(); - queueServices.Count.ShouldBe(3); + var queues = GetQueueDefinitions(Target); + queues.Count.ShouldBe(3); } - /// - /// Executes this member. - /// [Fact] public void RegisterRoutingKeyFormatterShouldStoreFormatterForType() { @@ -143,21 +120,18 @@ public void RegisterRoutingKeyFormatterShouldStoreFormatterForType() var options = new MessagingOptions(); // Act - var messagingConfigurator = new MessagingConfigurator(CreateHostBuilder(), options); + var messagingConfigurator = new MessagingConfigurator(Target, options); messagingConfigurator.ConfigureMessage(pd => pd.UseRoutingKey("test.route")); // Assert - options.MessageConfigurations.ContainsKey(typeof(TestMessage)).ShouldBeTrue(); - var def = options.MessageConfigurations[typeof(TestMessage)]; + options.MessageConfigurations.ContainsKey(typeof(TestMessage).FullName!).ShouldBeTrue(); + var def = options.MessageConfigurations[typeof(TestMessage).FullName!]; def.RoutingKeyFormatter .ShouldNotBeNull() .Invoke(It.IsAny()) .ShouldBe("test.route"); } - /// - /// Executes this member. - /// [Fact] public void RegisterRoutingKeyFormatterWithFuncShouldUseCustomLogic() { @@ -166,18 +140,15 @@ public void RegisterRoutingKeyFormatterWithFuncShouldUseCustomLogic() var options = new MessagingOptions(); // Act - var messagingConfigurator = new MessagingConfigurator(CreateHostBuilder(), options); + var messagingConfigurator = new MessagingConfigurator(Target, options); messagingConfigurator.ConfigureMessage(pd => pd.UseRoutingKey((obj) => $"route.{obj.Id}")); // Assert - var def = options.MessageConfigurations[typeof(TestMessage)]; + var def = options.MessageConfigurations[typeof(TestMessage).FullName!]; def.RoutingKeyFormatter.ShouldNotBeNull(); def.RoutingKeyFormatter!(testMessage).ShouldBe("route.test-123"); } - /// - /// Executes this member. - /// [Fact] public void RegisterCorrelationIdFormatterShouldStoreFormatterForType() { @@ -186,22 +157,19 @@ public void RegisterCorrelationIdFormatterShouldStoreFormatterForType() var options = new MessagingOptions(); // Act - var messagingConfigurator = new MessagingConfigurator(CreateHostBuilder(), options); + var messagingConfigurator = new MessagingConfigurator(Target, options); messagingConfigurator.ConfigureMessage(pd => pd.UseCorrelationId((obj) => obj.Id)); // Assert - options.MessageConfigurations.ContainsKey(typeof(TestMessage)).ShouldBeTrue(); - var def = options.MessageConfigurations[typeof(TestMessage)]; + options.MessageConfigurations.ContainsKey(typeof(TestMessage).FullName!).ShouldBeTrue(); + var def = options.MessageConfigurations[typeof(TestMessage).FullName!]; def.CorrelationIdFormatter.ShouldNotBeNull() .Invoke(testMessage) .ShouldBe(testMessage.Id); } - private class TestMessage + private sealed record TestMessage { - /// - /// Gets or sets this member value. - /// public string Id { get; set; } = string.Empty; } } From ab8eabac7b89ff0b13546ae3770c28384f16e7d3 Mon Sep 17 00:00:00 2001 From: Vulthil Date: Mon, 25 May 2026 13:17:24 +0200 Subject: [PATCH 03/42] feat(messaging): add IConsumeFilter pipeline for consumer cross-cutting concerns Introduce a middleware-style consume pipeline so cross-cutting concerns (logging, validation, scoped resource management, telemetry, etc.) can be composed around the consumer invocation without modifying transport or consumer code. Public surface (Abstractions): - ConsumeDelegate: delegate signature for the next pipeline stage. - IConsumeFilter: middleware-style filter that wraps consumer invocation. Filters may short-circuit by not invoking next. Public surface (Vulthil.Messaging): - IMessagingConfigurator.AddConsumeFilter(): closed-generic registration; auto-discovers all IConsumeFilter interfaces the implementation declares. - IMessagingConfigurator.AddOpenConsumeFilter(Type openFilterType): open-generic registration for filters that apply to every message type (mirrors the existing AddOpenPipelineHandler pattern in Vulthil.SharedKernel.Application). Internal changes (Vulthil.Messaging.RabbitMq): - ConsumePipelineFactory.Build(): composes the registered filters around a terminal delegate. First-registered = outermost. Returns the terminal unchanged when no filters are registered (no allocation on the cold path). - ConsumerInvoker: terminal is the consumer call. - RpcInvoker: terminal captures the response via closure; outer try/catch guarantees a response is sent even when a filter throws or short-circuits (MessageResult.Failure with explanatory text). IMessageConfigurationProvider is now resolved at invoke-time so its JsonSerializerOptions can come from the scoped provider. Tests (5 new in ConsumeFilterPipelineTests): - No filters routes straight to the consumer. - Multiple filters compose outer-first / inner-last around the consumer. - Short-circuiting a filter prevents consumer invocation. - RPC pipeline wraps the consumer call and still publishes the response. - RPC short-circuit produces a failure response instead of a timeout. Test-infra fixes: - MessageTypeCacheTests nested fixture types (TestMessage, TestRequest, TestResponse) promoted from private to internal so Castle.DynamicProxy can proxy generic instantiations of IConsumeFilter against the AutoMocker service provider. - MessageTypeCacheTests registers an empty IEnumerable> for each tested message type so AutoMocker does not auto-mock the pipeline into a silent no-op. Docs: - New "Consume Filters" section in docs/articles/messaging.md covering the interface shape, registration (open + closed), filter ordering, short-circuit semantics, and RPC behavior. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/articles/messaging.md | 82 +++++ .../Consumers/IConsumeFilter.cs | 34 ++ .../PublicAPI.Unshipped.txt | 3 + .../Consumers/IConsumerInvoker.cs | 82 ++++- .../IMessagingConfigurator.cs | 18 ++ .../MessagingConfigurator.cs | 53 ++++ src/Vulthil.Messaging/PublicAPI.Unshipped.txt | 2 + .../ConsumeFilterPipelineTests.cs | 297 ++++++++++++++++++ .../MessageTypeCacheTests.cs | 14 +- 9 files changed, 571 insertions(+), 14 deletions(-) create mode 100644 src/Vulthil.Messaging.Abstractions/Consumers/IConsumeFilter.cs create mode 100644 tests/Vulthil.Messaging.RabbitMq.Tests/ConsumeFilterPipelineTests.cs diff --git a/docs/articles/messaging.md b/docs/articles/messaging.md index efb5061..e47c233 100644 --- a/docs/articles/messaging.md +++ b/docs/articles/messaging.md @@ -122,6 +122,88 @@ public sealed class OrderCreatedConsumer : IConsumer `IMessageContext.CancellationToken` exposes the delivery's cancellation token for handlers that want to observe it alongside the explicit method parameter. +## Consume Filters + +Consume filters wrap the consumer invocation, allowing cross-cutting concerns +(logging, validation, scoped resource management, telemetry, etc.) to be composed +without modifying transport or consumer code. They mirror the ASP.NET Core +middleware shape: + +```csharp +public sealed class LoggingConsumeFilter : IConsumeFilter + where TMessage : notnull +{ + private readonly ILogger> _logger; + + public LoggingConsumeFilter(ILogger> logger) + => _logger = logger; + + public async Task ConsumeAsync(IMessageContext context, ConsumeDelegate next) + { + _logger.LogInformation("Consuming {Type} (correlation={CorrelationId})", + typeof(TMessage).Name, context.CorrelationId); + try + { + await next(context); + _logger.LogInformation("Consumed {Type}", typeof(TMessage).Name); + } + catch (Exception ex) + { + _logger.LogError(ex, "Consume of {Type} failed", typeof(TMessage).Name); + throw; + } + } +} +``` + +### Registering filters + +```csharp +builder.AddMessaging(messaging => +{ + messaging.UseRabbitMq(); + + // Open-generic — applies to every message type + messaging.AddOpenConsumeFilter(typeof(LoggingConsumeFilter<>)); + + // Closed-generic — applies only to OrderCreatedEvent + messaging.AddConsumeFilter(); + + messaging.ConfigureQueue("order-events", queue => + { + queue.AddConsumer(); + }); +}); +``` + +Filters are resolved per delivery from the same scope as the consumer, so they +may depend on scoped services (e.g. DbContext, scoped ILogger<T>). +Multiple filters compose in registration order — the first registered is the +outermost. + +### Short-circuiting + +A filter may skip calling `next` to reject a message: + +```csharp +public sealed class TenantGate : IConsumeFilter + where TMessage : notnull +{ + public Task ConsumeAsync(IMessageContext context, ConsumeDelegate next) + { + if (context.Headers.TryGetValue("Tenant", out var t) && t is "blocked") + { + // Don't invoke next — consumer is skipped, delivery is acked normally. + return Task.CompletedTask; + } + return next(context); + } +} +``` + +For request/reply consumers, short-circuiting causes the requester to receive a +`Result` failure (with an explanatory error) instead of timing out. + ## Routing Keys Routing keys control which consumers receive a message on topic exchanges. diff --git a/src/Vulthil.Messaging.Abstractions/Consumers/IConsumeFilter.cs b/src/Vulthil.Messaging.Abstractions/Consumers/IConsumeFilter.cs new file mode 100644 index 0000000..789c304 --- /dev/null +++ b/src/Vulthil.Messaging.Abstractions/Consumers/IConsumeFilter.cs @@ -0,0 +1,34 @@ +namespace Vulthil.Messaging.Abstractions.Consumers; + +/// +/// Delegate that invokes the next stage in the consume pipeline. +/// +/// The message type being consumed. +/// The message context for the current delivery. +/// A task representing the asynchronous operation. +public delegate Task ConsumeDelegate(IMessageContext context) + where TMessage : notnull; + +/// +/// A filter in the consume pipeline. Filters wrap the consumer invocation, allowing +/// cross-cutting concerns (logging, validation, scoped resource management, telemetry, etc.) +/// to be composed without modifying transport or consumer code. +/// +/// The message type the filter applies to. +/// +/// Filters are composed in registration order: the first registered filter is the +/// outermost. A filter may short-circuit the pipeline by not invoking the +/// next delegate, e.g. to reject a message based on a validation rule. +/// Filters are resolved per delivery from the same scope as the consumer, so they may +/// depend on scoped services (e.g. DbContext, scoped ILogger<T>). +/// +public interface IConsumeFilter where TMessage : notnull +{ + /// + /// Processes the message, optionally invoking to continue the pipeline. + /// + /// The message context. + /// The next stage of the pipeline. The terminal stage invokes the consumer. + /// A task representing the asynchronous operation. + Task ConsumeAsync(IMessageContext context, ConsumeDelegate next); +} diff --git a/src/Vulthil.Messaging.Abstractions/PublicAPI.Unshipped.txt b/src/Vulthil.Messaging.Abstractions/PublicAPI.Unshipped.txt index 954d07f..834c42d 100644 --- a/src/Vulthil.Messaging.Abstractions/PublicAPI.Unshipped.txt +++ b/src/Vulthil.Messaging.Abstractions/PublicAPI.Unshipped.txt @@ -12,6 +12,9 @@ Vulthil.Messaging.Abstractions.Consumers.Fault.OriginalContext.get -> Vulthil.Messaging.Abstractions.Consumers.Fault.OriginalContext.init -> void Vulthil.Messaging.Abstractions.Consumers.Fault.StackTrace.get -> string? Vulthil.Messaging.Abstractions.Consumers.Fault.StackTrace.init -> void +Vulthil.Messaging.Abstractions.Consumers.ConsumeDelegate +Vulthil.Messaging.Abstractions.Consumers.IConsumeFilter +Vulthil.Messaging.Abstractions.Consumers.IConsumeFilter.ConsumeAsync(Vulthil.Messaging.Abstractions.Consumers.IMessageContext! context, Vulthil.Messaging.Abstractions.Consumers.ConsumeDelegate! next) -> System.Threading.Tasks.Task! Vulthil.Messaging.Abstractions.Consumers.IConsumer Vulthil.Messaging.Abstractions.Consumers.IConsumer Vulthil.Messaging.Abstractions.Consumers.IConsumer.ConsumeAsync(Vulthil.Messaging.Abstractions.Consumers.IMessageContext! messageContext, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! diff --git a/src/Vulthil.Messaging.RabbitMq/Consumers/IConsumerInvoker.cs b/src/Vulthil.Messaging.RabbitMq/Consumers/IConsumerInvoker.cs index 19d7acf..a3f3f47 100644 --- a/src/Vulthil.Messaging.RabbitMq/Consumers/IConsumerInvoker.cs +++ b/src/Vulthil.Messaging.RabbitMq/Consumers/IConsumerInvoker.cs @@ -16,6 +16,38 @@ internal interface IConsumerInvoker RetryPolicyDefinition? RetryPolicy { get; } Task InvokeAsync(IServiceProvider sp, object message, BasicDeliverEventArgs ea, CancellationToken ct); } + +internal static class ConsumePipelineFactory +{ + /// + /// Composes the registered instances around a terminal + /// delegate. The first filter resolved from DI becomes the outermost; the terminal delegate + /// runs innermost. + /// + public static ConsumeDelegate Build( + IServiceProvider sp, + ConsumeDelegate terminal) + where TMessage : notnull + { + var filters = sp.GetServices>().ToArray(); + if (filters.Length == 0) + { + return terminal; + } + + var pipeline = terminal; + // Iterate in reverse so the first-registered filter ends up outermost. + for (var i = filters.Length - 1; i >= 0; i--) + { + var filter = filters[i]; + var next = pipeline; + pipeline = context => filter.ConsumeAsync(context, next); + } + + return pipeline; + } +} + internal sealed class ConsumerInvoker(string routingKey, RetryPolicyDefinition? retryPolicy) : IConsumerInvoker where TConsumer : class, IConsumer where TMessage : notnull @@ -43,7 +75,11 @@ public async Task InvokeAsync( var publisher = sp.GetRequiredService(); var context = MessageContext.CreateContext((TMessage)message, ea, publisher, ct); - await consumer.ConsumeAsync(context, ct); + var pipeline = ConsumePipelineFactory.Build( + sp, + terminal: c => consumer.ConsumeAsync(c, c.CancellationToken)); + + await pipeline(context); } } @@ -59,28 +95,52 @@ internal sealed class RpcInvoker(string routingK where TRequest : notnull where TResponse : notnull { + /// + /// Represents this member. + /// public string RoutingKey => routingKey; - + /// + /// Represents this member. + /// public RetryPolicyDefinition? RetryPolicy => retryPolicy; + /// + /// Executes this member. + /// public async Task InvokeAsync(IServiceProvider sp, object message, BasicDeliverEventArgs ea, IChannel channel, CancellationToken ct) { - var messageConfigurationProvider = sp.GetRequiredService(); - var jsonOptions = messageConfigurationProvider.JsonSerializerOptions; var consumer = sp.GetRequiredService(); var publisher = sp.GetRequiredService(); - + var jsonOptions = sp.GetRequiredService().JsonSerializerOptions; var context = MessageContext.CreateContext((TRequest)message, ea, publisher, ct); MessageResult messageResult; try { - // Execute consumer and get response - TResponse response = await consumer.ConsumeAsync(context, ct); - - var responseByteArray = JsonSerializer.SerializeToUtf8Bytes(response, jsonOptions); - - messageResult = MessageResult.Success(responseByteArray); + // The terminal stage captures the consumer's response so any wrapping filters can + // observe completion (e.g. for telemetry) before we serialize and publish it. + TResponse response = default!; + var responseProduced = false; + + var pipeline = ConsumePipelineFactory.Build( + sp, + terminal: async c => + { + response = await consumer.ConsumeAsync(c, c.CancellationToken); + responseProduced = true; + }); + + await pipeline(context); + + if (!responseProduced) + { + messageResult = MessageResult.Failure("Consume pipeline did not produce a response (a filter likely short-circuited the chain)."); + } + else + { + var responseByteArray = JsonSerializer.SerializeToUtf8Bytes(response, jsonOptions); + messageResult = MessageResult.Success(responseByteArray); + } } catch (Exception exception) { diff --git a/src/Vulthil.Messaging/IMessagingConfigurator.cs b/src/Vulthil.Messaging/IMessagingConfigurator.cs index 606bffb..dd4acfa 100644 --- a/src/Vulthil.Messaging/IMessagingConfigurator.cs +++ b/src/Vulthil.Messaging/IMessagingConfigurator.cs @@ -35,4 +35,22 @@ IMessagingConfigurator ConfigureMessage(ActionAn action to configure the messaging options. /// The current configurator instance for chaining. IMessagingConfigurator ConfigureMessagingOptions(Action action); + + /// + /// Registers a closed-generic consume filter. The filter is applied to every delivery whose + /// message type matches the filter's IConsumeFilter<TMessage> interface. + /// Multiple filters for the same message type are composed in registration order + /// (first registered is outermost). + /// + /// The filter implementation. Must implement at least one IConsumeFilter<TMessage>. + /// The current configurator instance for chaining. + IMessagingConfigurator AddConsumeFilter() where TFilter : class; + + /// + /// Registers an open-generic consume filter that applies to every message type. Use this for + /// cross-cutting filters that do not depend on the typed payload (logging, telemetry, etc.). + /// + /// An open generic type (e.g. typeof(LoggingFilter<>)) implementing IConsumeFilter<>. + /// The current configurator instance for chaining. + IMessagingConfigurator AddOpenConsumeFilter(Type openFilterType); } diff --git a/src/Vulthil.Messaging/MessagingConfigurator.cs b/src/Vulthil.Messaging/MessagingConfigurator.cs index 30989fd..a762d9c 100644 --- a/src/Vulthil.Messaging/MessagingConfigurator.cs +++ b/src/Vulthil.Messaging/MessagingConfigurator.cs @@ -1,6 +1,8 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; +using Vulthil.Messaging.Abstractions.Consumers; using Vulthil.Messaging.Queues; namespace Vulthil.Messaging; @@ -96,4 +98,55 @@ private static void CopyMessageConfiguration(MessageConfiguration src, MessageCo dst.RoutingKeyFormatter = src.RoutingKeyFormatter; dst.CorrelationIdFormatter = src.CorrelationIdFormatter; } + + public IMessagingConfigurator AddConsumeFilter() where TFilter : class + { + var filterType = typeof(TFilter); + var filterInterfaces = filterType.GetInterfaces() + .Where(i => i.IsGenericType && !i.IsGenericTypeDefinition && i.GetGenericTypeDefinition() == typeof(IConsumeFilter<>)) + .ToList(); + + if (filterInterfaces.Count == 0) + { + throw new InvalidOperationException( + $"'{filterType.FullName}' must implement at least one '{typeof(IConsumeFilter<>).FullName}' interface to be registered as a consume filter."); + } + + foreach (var iface in filterInterfaces) + { + Services.TryAddEnumerable(new ServiceDescriptor(iface, filterType, ServiceLifetime.Scoped)); + } + + return this; + } + + public IMessagingConfigurator AddOpenConsumeFilter(Type openFilterType) + { + ArgumentNullException.ThrowIfNull(openFilterType); + + if (!openFilterType.IsGenericTypeDefinition) + { + throw new InvalidOperationException( + $"'{openFilterType.FullName}' must be an open generic type (e.g. typeof(MyFilter<>)) to be registered as an open consume filter."); + } + + var implementsFilter = false; + foreach (var iface in openFilterType.GetInterfaces()) + { + if (iface.IsGenericType && iface.GetGenericTypeDefinition() == typeof(IConsumeFilter<>)) + { + implementsFilter = true; + break; + } + } + + if (!implementsFilter) + { + throw new InvalidOperationException( + $"'{openFilterType.FullName}' must implement '{typeof(IConsumeFilter<>).FullName}' to be registered as an open consume filter."); + } + + Services.TryAddEnumerable(new ServiceDescriptor(typeof(IConsumeFilter<>), openFilterType, ServiceLifetime.Scoped)); + return this; + } } diff --git a/src/Vulthil.Messaging/PublicAPI.Unshipped.txt b/src/Vulthil.Messaging/PublicAPI.Unshipped.txt index f5036c7..4fca7d4 100644 --- a/src/Vulthil.Messaging/PublicAPI.Unshipped.txt +++ b/src/Vulthil.Messaging/PublicAPI.Unshipped.txt @@ -8,6 +8,8 @@ Vulthil.Messaging.IMessageConfigurationProvider.GetMessageConfiguration(System.Action!>! configureMessageAction) -> Vulthil.Messaging.IMessagingConfigurator! Vulthil.Messaging.IMessagingConfigurator.ConfigureMessagingOptions(System.Action! action) -> Vulthil.Messaging.IMessagingConfigurator! +Vulthil.Messaging.IMessagingConfigurator.AddConsumeFilter() -> Vulthil.Messaging.IMessagingConfigurator! +Vulthil.Messaging.IMessagingConfigurator.AddOpenConsumeFilter(System.Type! openFilterType) -> Vulthil.Messaging.IMessagingConfigurator! Vulthil.Messaging.IMessagingConfigurator.ConfigureQueue(string! queueName, System.Action! queueConfigurationAction) -> Vulthil.Messaging.IMessagingConfigurator! Vulthil.Messaging.IMessagingConfigurator.HostApplicationBuilder.get -> Microsoft.Extensions.Hosting.IHostApplicationBuilder! Vulthil.Messaging.ITransport diff --git a/tests/Vulthil.Messaging.RabbitMq.Tests/ConsumeFilterPipelineTests.cs b/tests/Vulthil.Messaging.RabbitMq.Tests/ConsumeFilterPipelineTests.cs new file mode 100644 index 0000000..20394b3 --- /dev/null +++ b/tests/Vulthil.Messaging.RabbitMq.Tests/ConsumeFilterPipelineTests.cs @@ -0,0 +1,297 @@ +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using RabbitMQ.Client; +using RabbitMQ.Client.Events; +using Vulthil.Messaging.Abstractions.Consumers; +using Vulthil.Messaging.Abstractions.Publishers; +using Vulthil.Messaging.Queues; +using Vulthil.Messaging.RabbitMq.Consumers; +using Vulthil.Messaging.RabbitMq.Requests; +using Vulthil.xUnit; + +namespace Vulthil.Messaging.RabbitMq.Tests; + +public sealed class ConsumeFilterPipelineTests : BaseUnitTestCase +{ + private readonly Lazy _lazyTarget; + private MessageTypeCache Target => _lazyTarget.Value; + + public ConsumeFilterPipelineTests() + { + _lazyTarget = new Lazy(CreateInstance); + } + + private static BasicDeliverEventArgs CreateDeliverEventArgs(string routingKey = "#", string? replyTo = null, string? correlationId = null) + { + return new BasicDeliverEventArgs( + "consumer-tag", + 1, + false, + "test-exchange", + routingKey, + new BasicProperties + { + ReplyTo = replyTo, + CorrelationId = correlationId, + Headers = new Dictionary() + }, + ReadOnlyMemory.Empty); + } + + internal sealed record TestMessage(string Content); + internal sealed record TestRequest(string Query); + internal sealed record TestResponse(string Result); + + private sealed class RecordingConsumer : IConsumer + { + public List Received { get; } = []; + + public Task ConsumeAsync(IMessageContext messageContext, CancellationToken cancellationToken = default) + { + Received.Add(messageContext.Message); + return Task.CompletedTask; + } + } + + private sealed class RecordingRequestConsumer : IRequestConsumer + { + public List Received { get; } = []; + + public Task ConsumeAsync(IMessageContext messageContext, CancellationToken cancellationToken = default) + { + Received.Add(messageContext.Message); + return Task.FromResult(new TestResponse($"Processed: {messageContext.Message.Query}")); + } + } + + private sealed class RecordingFilter(List trace, string name) : IConsumeFilter + where TMessage : notnull + { + public List Trace { get; } = trace; + public string Name { get; } = name; + public bool ShortCircuit { get; set; } + + public async Task ConsumeAsync(IMessageContext context, ConsumeDelegate next) + { + Trace.Add($"{Name}:before"); + + if (ShortCircuit) + { + Trace.Add($"{Name}:short-circuit"); + return; + } + + await next(context); + Trace.Add($"{Name}:after"); + } + } + + [Fact] + public async Task PipelineWithNoFiltersInvokesConsumerDirectly() + { + // Arrange + var consumerInstance = new RecordingConsumer(); + var services = new ServiceCollection(); + services.AddScoped(_ => consumerInstance); + services.AddSingleton(Mock.Of()); + var serviceProvider = services.BuildServiceProvider(); + + var queue = new QueueDefinition("TestQueue"); + queue.AddConsumer(new ConsumerRegistration + { + ConsumerType = new ConsumerType(typeof(RecordingConsumer)), + MessageType = new MessageType(typeof(TestMessage)), + RoutingKey = "#" + }); + Target.RegisterQueue(queue); + + var handler = Target.GetPlan(new MessageType(typeof(TestMessage)).Name)!.StandardHandlers[0]; + + // Act + await handler.InvokeAsync(serviceProvider, new TestMessage("payload"), CreateDeliverEventArgs(), CancellationToken.None); + + // Assert + consumerInstance.Received.ShouldHaveSingleItem().Content.ShouldBe("payload"); + } + + [Fact] + public async Task PipelineComposesFiltersInRegistrationOrderOutermostFirst() + { + // Arrange + var trace = new List(); + var consumerInstance = new RecordingConsumer(); + var services = new ServiceCollection(); + services.AddScoped(_ => consumerInstance); + services.AddSingleton(Mock.Of()); + // Order matters: First registered should be outermost. + services.AddScoped>(_ => new RecordingFilter(trace, "outer")); + services.AddScoped>(_ => new RecordingFilter(trace, "inner")); + var serviceProvider = services.BuildServiceProvider(); + + var queue = new QueueDefinition("TestQueue"); + queue.AddConsumer(new ConsumerRegistration + { + ConsumerType = new ConsumerType(typeof(RecordingConsumer)), + MessageType = new MessageType(typeof(TestMessage)), + RoutingKey = "#" + }); + Target.RegisterQueue(queue); + + var handler = Target.GetPlan(new MessageType(typeof(TestMessage)).Name)!.StandardHandlers[0]; + + // Act + await handler.InvokeAsync(serviceProvider, new TestMessage("payload"), CreateDeliverEventArgs(), CancellationToken.None); + + // Assert + trace.ShouldBe(["outer:before", "inner:before", "inner:after", "outer:after"]); + consumerInstance.Received.ShouldHaveSingleItem(); + } + + [Fact] + public async Task FilterShortCircuitPreventsConsumerInvocation() + { + // Arrange + var trace = new List(); + var consumerInstance = new RecordingConsumer(); + var services = new ServiceCollection(); + services.AddScoped(_ => consumerInstance); + services.AddSingleton(Mock.Of()); + services.AddScoped>(_ => + new RecordingFilter(trace, "gate") { ShortCircuit = true }); + var serviceProvider = services.BuildServiceProvider(); + + var queue = new QueueDefinition("TestQueue"); + queue.AddConsumer(new ConsumerRegistration + { + ConsumerType = new ConsumerType(typeof(RecordingConsumer)), + MessageType = new MessageType(typeof(TestMessage)), + RoutingKey = "#" + }); + Target.RegisterQueue(queue); + + var handler = Target.GetPlan(new MessageType(typeof(TestMessage)).Name)!.StandardHandlers[0]; + + // Act + await handler.InvokeAsync(serviceProvider, new TestMessage("payload"), CreateDeliverEventArgs(), CancellationToken.None); + + // Assert + trace.ShouldBe(["gate:before", "gate:short-circuit"]); + consumerInstance.Received.ShouldBeEmpty(); + } + + [Fact] + public async Task RpcPipelineComposesFiltersAroundConsumerCall() + { + // Arrange + var trace = new List(); + var consumerInstance = new RecordingRequestConsumer(); + var services = new ServiceCollection(); + services.AddScoped(_ => consumerInstance); + services.AddSingleton(Mock.Of()); + services.AddSingleton(new MessageConfigurationProvider(new MessagingOptions())); + services.AddScoped>(_ => new RecordingFilter(trace, "log")); + var serviceProvider = services.BuildServiceProvider(); + + var queue = new QueueDefinition("TestQueue"); + queue.AddConsumer(new RequestConsumerRegistration + { + ConsumerType = new ConsumerType(typeof(RecordingRequestConsumer)), + MessageType = new MessageType(typeof(TestRequest)), + ResponseType = typeof(TestResponse), + RoutingKey = "#" + }); + Target.RegisterQueue(queue); + + var handler = Target.GetPlan(new MessageType(typeof(TestRequest)).Name)!.RpcHandler!; + + var channel = GetMock(); + ReadOnlyMemory publishedBody = default; + channel.Setup(x => x.BasicPublishAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Callback((string _, string _, bool _, BasicProperties _, ReadOnlyMemory body, CancellationToken _) => + { + publishedBody = body; + }) + .Returns(ValueTask.CompletedTask); + + // Act + await handler.InvokeAsync( + serviceProvider, + new TestRequest("query"), + CreateDeliverEventArgs(replyTo: "reply", correlationId: "corr-1"), + channel.Object, + CancellationToken.None); + + // Assert + trace.ShouldBe(["log:before", "log:after"]); + consumerInstance.Received.ShouldHaveSingleItem(); + + var envelope = JsonSerializer.Deserialize(publishedBody.Span); + envelope.ShouldNotBeNull(); + envelope.IsSuccess.ShouldBeTrue(); + var response = JsonSerializer.Deserialize(envelope.Value); + response!.Result.ShouldBe("Processed: query"); + } + + [Fact] + public async Task RpcPipelineShortCircuitProducesFailureResponse() + { + // Arrange + var consumerInstance = new RecordingRequestConsumer(); + var services = new ServiceCollection(); + services.AddScoped(_ => consumerInstance); + services.AddSingleton(Mock.Of()); + services.AddSingleton(new MessageConfigurationProvider(new MessagingOptions())); + services.AddScoped>(_ => + new RecordingFilter([], "gate") { ShortCircuit = true }); + var serviceProvider = services.BuildServiceProvider(); + + var queue = new QueueDefinition("TestQueue"); + queue.AddConsumer(new RequestConsumerRegistration + { + ConsumerType = new ConsumerType(typeof(RecordingRequestConsumer)), + MessageType = new MessageType(typeof(TestRequest)), + ResponseType = typeof(TestResponse), + RoutingKey = "#" + }); + Target.RegisterQueue(queue); + + var handler = Target.GetPlan(new MessageType(typeof(TestRequest)).Name)!.RpcHandler!; + + var channel = GetMock(); + ReadOnlyMemory publishedBody = default; + channel.Setup(x => x.BasicPublishAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Callback((string _, string _, bool _, BasicProperties _, ReadOnlyMemory body, CancellationToken _) => + { + publishedBody = body; + }) + .Returns(ValueTask.CompletedTask); + + // Act + await handler.InvokeAsync( + serviceProvider, + new TestRequest("query"), + CreateDeliverEventArgs(replyTo: "reply"), + channel.Object, + CancellationToken.None); + + // Assert + consumerInstance.Received.ShouldBeEmpty(); + + var envelope = JsonSerializer.Deserialize(publishedBody.Span); + envelope.ShouldNotBeNull(); + envelope.IsSuccess.ShouldBeFalse(); + envelope.ErrorMessage.ShouldContain("short-circuit"); + } +} diff --git a/tests/Vulthil.Messaging.RabbitMq.Tests/MessageTypeCacheTests.cs b/tests/Vulthil.Messaging.RabbitMq.Tests/MessageTypeCacheTests.cs index 001dbfb..b10a288 100644 --- a/tests/Vulthil.Messaging.RabbitMq.Tests/MessageTypeCacheTests.cs +++ b/tests/Vulthil.Messaging.RabbitMq.Tests/MessageTypeCacheTests.cs @@ -22,6 +22,14 @@ public sealed class MessageTypeCacheTests : BaseUnitTestCase public MessageTypeCacheTests() { _lazyTarget = new Lazy(CreateInstance); + // Register a real configuration provider so the RPC invoker can resolve JsonSerializerOptions + // through the scoped resolver path instead of falling back to AutoMocker's auto-mocked default. + Use(new MessageConfigurationProvider(new MessagingOptions())); + // AutoMocker auto-mocks every requested service, including IEnumerable>, + // and its default mock enumerable yields a mocked filter whose no-op ConsumeAsync silently + // short-circuits the pipeline. Register empty arrays explicitly to opt out for tested types. + Use>>([]); + Use>>([]); _serviceProvider = AutoMocker; } @@ -44,9 +52,9 @@ private static BasicDeliverEventArgs CreateDeliverEventArgs(string routingKey = #region Test Messages and Consumers - private sealed record TestMessage(string Content); - private sealed record TestRequest(string Query); - private sealed record TestResponse(string Result); + internal sealed record TestMessage(string Content); + internal sealed record TestRequest(string Query); + internal sealed record TestResponse(string Result); private sealed class TestMessageConsumer : IConsumer { From fa8ff7332ff660d4a2325c43b68dc509e0f4cdcc Mon Sep 17 00:00:00 2001 From: Vulthil Date: Mon, 25 May 2026 13:26:31 +0200 Subject: [PATCH 04/42] feat(messaging): add LoggingConsumeFilter as default opt-out filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ship a default open-generic LoggingConsumeFilter registered as the outermost layer of every consume pipeline. It emits structured Debug logs at consume entry/exit and a Warning log on uncaught exceptions, with timing information from Stopwatch.GetTimestamp. New public surface: - ConsumeFilterOptions class exposing per-default-filter toggles, currently EnableLogging (default true). - MessagingOptions.ConsumeFilters property returning the live options object; bind via Messaging:Options:ConsumeFilters in appsettings or mutate via ConfigureMessagingOptions. Registration: - AddMessaging registers LoggingConsumeFilter<> via TryAddEnumerable BEFORE running the configurator action so user-registered filters compose INSIDE the defaults (logging wraps everything). - The filter checks EnableLogging at construction time; when disabled, it passes through to next without logging or timing — the filter stays in DI for test resolution, only its behavior is suppressed. Tests: - DisabledFilterPassesThroughWithoutLogging - EnabledFilterLogsConsumingAndConsumedOnSuccess - EnabledFilterLogsWarningAndRethrowsOnException Tests use a RecordingLogger fake rather than Moq because LoggerMessage's generated code short-circuits on ILogger.IsEnabled, which a default Moq returns false from. Docs: - New "Built-in filters" subsection in docs/articles/messaging.md showing the logging output, the option toggle in appsettings, and the code-side ConfigureMessagingOptions path. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/articles/messaging.md | 35 +++++ src/Vulthil.Messaging/ConsumeFilterOptions.cs | 21 +++ src/Vulthil.Messaging/DependencyInjection.cs | 17 +++ .../Filters/LoggingConsumeFilter.cs | 63 +++++++++ src/Vulthil.Messaging/MessagingOptions.cs | 5 + src/Vulthil.Messaging/PublicAPI.Unshipped.txt | 5 + .../Vulthil.Messaging.csproj | 2 + .../Filters/LoggingConsumeFilterTests.cs | 122 ++++++++++++++++++ 8 files changed, 270 insertions(+) create mode 100644 src/Vulthil.Messaging/ConsumeFilterOptions.cs create mode 100644 src/Vulthil.Messaging/Filters/LoggingConsumeFilter.cs create mode 100644 tests/Vulthil.Messaging.Tests/Filters/LoggingConsumeFilterTests.cs diff --git a/docs/articles/messaging.md b/docs/articles/messaging.md index e47c233..0670cb8 100644 --- a/docs/articles/messaging.md +++ b/docs/articles/messaging.md @@ -204,6 +204,41 @@ public sealed class TenantGate : IConsumeFilter For request/reply consumers, short-circuiting causes the requester to receive a `Result` failure (with an explanatory error) instead of timing out. +### Built-in filters + +`AddMessaging` registers a default open-generic `LoggingConsumeFilter` as +the outermost filter in the pipeline. It emits structured Debug logs at consume +entry/exit and a Warning log on uncaught exceptions, with timing information: + +``` +dbug: Consuming Acme.Orders.OrderCreatedEvent (messageId=..., correlationId=...) +dbug: Consumed Acme.Orders.OrderCreatedEvent (messageId=...) in 12ms +``` + +User-registered filters compose INSIDE the defaults, so the logging filter wraps +every other filter and the consumer itself. + +Toggle the built-in filter via `MessagingOptions.ConsumeFilters`: + +```json +{ + "Messaging": { + "Options": { + "ConsumeFilters": { "EnableLogging": false } + } + } +} +``` + +Or in code: + +```csharp +m.ConfigureMessagingOptions(opts => opts.ConsumeFilters.EnableLogging = false); +``` + +The filter stays registered in DI; only its behavior is skipped, so it's still +resolvable in unit tests if you want to assert against it. + ## Routing Keys Routing keys control which consumers receive a message on topic exchanges. diff --git a/src/Vulthil.Messaging/ConsumeFilterOptions.cs b/src/Vulthil.Messaging/ConsumeFilterOptions.cs new file mode 100644 index 0000000..4dce2ac --- /dev/null +++ b/src/Vulthil.Messaging/ConsumeFilterOptions.cs @@ -0,0 +1,21 @@ +namespace Vulthil.Messaging; + +/// +/// Configures the built-in consume filters that AddMessaging registers by default. +/// Each flag toggles a specific filter on or off; setting a flag to +/// causes the filter to pass deliveries straight through without performing its work. +/// +/// +/// Filters remain registered in DI regardless of these flags so that user code can still +/// resolve them (e.g. for unit tests); the flag is checked at invocation time. To disable +/// every default filter, set each flag explicitly. +/// +public sealed class ConsumeFilterOptions +{ + /// + /// When (the default), + /// emits structured Debug logs at the start and end of every consume, plus a Warning log on + /// uncaught exceptions, with timing information. + /// + public bool EnableLogging { get; set; } = true; +} diff --git a/src/Vulthil.Messaging/DependencyInjection.cs b/src/Vulthil.Messaging/DependencyInjection.cs index 6616887..b681440 100644 --- a/src/Vulthil.Messaging/DependencyInjection.cs +++ b/src/Vulthil.Messaging/DependencyInjection.cs @@ -1,6 +1,10 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; +using Vulthil.Messaging.Abstractions.Consumers; +using Vulthil.Messaging.Filters; using Vulthil.Messaging.Queues; namespace Vulthil.Messaging; @@ -17,6 +21,8 @@ public static class DependencyInjection /// Queue definitions and message configurations under Messaging:Queues:* and Messaging:Messages:* /// are loaded from before runs. /// Code registrations performed inside the configurator action merge onto the loaded values and take precedence. + /// Built-in consume filters (see ) are registered before the configurator + /// action runs, so user-registered filters compose INSIDE the defaults. /// /// The host application builder. /// An action to configure messaging through . @@ -31,9 +37,20 @@ public static IServiceCollection AddMessaging(this IHostApplicationBuilder build builder.Services.AddHostedService(); + // Default consume filters register first so they sit outermost; user-added filters + // compose inside. Each default filter checks its own flag on ConsumeFilterOptions at + // invocation time, so toggling the flag in code or appsettings disables the work + // without unregistering the filter. + builder.Services.TryAddEnumerable(new ServiceDescriptor( + typeof(IConsumeFilter<>), + typeof(LoggingConsumeFilter<>), + ServiceLifetime.Scoped)); + var messagingConfigurator = new MessagingConfigurator(builder, messagingOptions); messagingConfiguratorAction(messagingConfigurator); + builder.Services.AddSingleton(Options.Create(messagingOptions)); + return builder.Services; } diff --git a/src/Vulthil.Messaging/Filters/LoggingConsumeFilter.cs b/src/Vulthil.Messaging/Filters/LoggingConsumeFilter.cs new file mode 100644 index 0000000..38b890f --- /dev/null +++ b/src/Vulthil.Messaging/Filters/LoggingConsumeFilter.cs @@ -0,0 +1,63 @@ +using System.Diagnostics; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Vulthil.Messaging.Abstractions.Consumers; + +namespace Vulthil.Messaging.Filters; + +/// +/// Default open-generic consume filter that emits structured Debug logs on consume entry/exit +/// and a Warning log on uncaught exceptions, with timing information. Registered as the +/// outermost filter by AddMessaging. Toggle via +/// . +/// +/// The consumed message type. +internal sealed class LoggingConsumeFilter( + ILogger> logger, + IOptions options) : IConsumeFilter + where TMessage : notnull +{ + private readonly ILogger> _logger = logger; + private readonly bool _enabled = options.Value.ConsumeFilters.EnableLogging; + + public async Task ConsumeAsync(IMessageContext context, ConsumeDelegate next) + { + if (!_enabled) + { + await next(context); + return; + } + + var messageType = typeof(TMessage).FullName ?? typeof(TMessage).Name; + FilterLog.Consuming(_logger, messageType, context.MessageId, context.CorrelationId); + + var startTimestamp = Stopwatch.GetTimestamp(); + try + { + await next(context); + var elapsedMs = (long)Stopwatch.GetElapsedTime(startTimestamp).TotalMilliseconds; + FilterLog.Consumed(_logger, messageType, context.MessageId, elapsedMs); + } + catch (Exception ex) + { + var elapsedMs = (long)Stopwatch.GetElapsedTime(startTimestamp).TotalMilliseconds; + FilterLog.ConsumeFailed(_logger, ex, messageType, context.MessageId, elapsedMs); + throw; + } + } +} + +internal static partial class FilterLog +{ + [LoggerMessage(EventId = 2000, Level = LogLevel.Debug, + Message = "Consuming {MessageType} (messageId={MessageId}, correlationId={CorrelationId})")] + public static partial void Consuming(ILogger logger, string messageType, string? messageId, string? correlationId); + + [LoggerMessage(EventId = 2001, Level = LogLevel.Debug, + Message = "Consumed {MessageType} (messageId={MessageId}) in {ElapsedMs}ms")] + public static partial void Consumed(ILogger logger, string messageType, string? messageId, long elapsedMs); + + [LoggerMessage(EventId = 2002, Level = LogLevel.Warning, + Message = "Consume of {MessageType} (messageId={MessageId}) failed after {ElapsedMs}ms")] + public static partial void ConsumeFailed(ILogger logger, Exception exception, string messageType, string? messageId, long elapsedMs); +} diff --git a/src/Vulthil.Messaging/MessagingOptions.cs b/src/Vulthil.Messaging/MessagingOptions.cs index eb13cce..105fa2a 100644 --- a/src/Vulthil.Messaging/MessagingOptions.cs +++ b/src/Vulthil.Messaging/MessagingOptions.cs @@ -29,6 +29,11 @@ public sealed class MessagingOptions /// public string FaultExchangeName { get; set; } = "Fault.Exchange"; + /// + /// Gets the options that control which built-in consume filters are active. + /// + public ConsumeFilterOptions ConsumeFilters { get; } = new(); + /// /// Message configurations keyed by the CLR full type name. Populated eagerly from Messaging:Messages:* /// at AddMessaging time, then merged with whatever ConfigureMessage<T> registers in code. diff --git a/src/Vulthil.Messaging/PublicAPI.Unshipped.txt b/src/Vulthil.Messaging/PublicAPI.Unshipped.txt index 4fca7d4..a5c75d4 100644 --- a/src/Vulthil.Messaging/PublicAPI.Unshipped.txt +++ b/src/Vulthil.Messaging/PublicAPI.Unshipped.txt @@ -1,5 +1,10 @@ #nullable enable const Vulthil.Messaging.MessagingOptions.SectionName = "Messaging:Options" -> string! +Vulthil.Messaging.ConsumeFilterOptions +Vulthil.Messaging.ConsumeFilterOptions.ConsumeFilterOptions() -> void +Vulthil.Messaging.ConsumeFilterOptions.EnableLogging.get -> bool +Vulthil.Messaging.ConsumeFilterOptions.EnableLogging.set -> void +Vulthil.Messaging.MessagingOptions.ConsumeFilters.get -> Vulthil.Messaging.ConsumeFilterOptions! static Vulthil.Messaging.DependencyInjection.AddMessaging(this Microsoft.Extensions.Hosting.IHostApplicationBuilder! builder, System.Action! messagingConfiguratorAction) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! Vulthil.Messaging.DependencyInjection Vulthil.Messaging.IMessageConfigurationProvider.FaultExchangeName.get -> string! diff --git a/src/Vulthil.Messaging/Vulthil.Messaging.csproj b/src/Vulthil.Messaging/Vulthil.Messaging.csproj index 12ec210..f14a6ff 100644 --- a/src/Vulthil.Messaging/Vulthil.Messaging.csproj +++ b/src/Vulthil.Messaging/Vulthil.Messaging.csproj @@ -8,6 +8,7 @@ + @@ -16,6 +17,7 @@ + diff --git a/tests/Vulthil.Messaging.Tests/Filters/LoggingConsumeFilterTests.cs b/tests/Vulthil.Messaging.Tests/Filters/LoggingConsumeFilterTests.cs new file mode 100644 index 0000000..17b4692 --- /dev/null +++ b/tests/Vulthil.Messaging.Tests/Filters/LoggingConsumeFilterTests.cs @@ -0,0 +1,122 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Vulthil.Messaging.Abstractions.Consumers; +using Vulthil.Messaging.Abstractions.Publishers; +using Vulthil.Messaging.Filters; +using Vulthil.xUnit; + +namespace Vulthil.Messaging.Tests.Filters; + +public sealed class LoggingConsumeFilterTests : BaseUnitTestCase +{ + internal sealed record TestMessage(string Content); + + private sealed class StubMessageContext : IMessageContext + { + public TestMessage Message { get; init; } = new(""); + public string? MessageId { get; init; } + public string? RequestId { get; init; } + public string? ConversationId { get; init; } + public string? CorrelationId { get; init; } + public string? InitiatorId { get; init; } + public Uri? SourceAddress { get; init; } + public Uri? DestinationAddress { get; init; } + public Uri? ResponseAddress { get; init; } + public Uri? FaultAddress { get; init; } + public string RoutingKey { get; init; } = string.Empty; + public IDictionary Headers { get; init; } = new Dictionary(); + public DateTimeOffset? SentTime { get; init; } + public DateTimeOffset? ExpirationTime { get; init; } + public int RetryCount { get; init; } + public bool Redelivered { get; init; } + public CancellationToken CancellationToken { get; init; } + public Task PublishAsync(TMsg message, Func? configure = null) where TMsg : notnull + => Task.CompletedTask; + } + + private sealed record LogRecord(LogLevel Level, EventId EventId, Exception? Exception, string Message); + + private sealed class RecordingLogger : ILogger + { + public List Records { get; } = []; + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + => Records.Add(new LogRecord(logLevel, eventId, exception, formatter(state, exception))); + } + + private static LoggingConsumeFilter CreateFilter(ILogger> logger, bool enabled) + { + var options = new MessagingOptions(); + options.ConsumeFilters.EnableLogging = enabled; + return new LoggingConsumeFilter(logger, Options.Create(options)); + } + + [Fact] + public async Task DisabledFilterPassesThroughWithoutLogging() + { + // Arrange + var logger = new RecordingLogger>(); + var filter = CreateFilter(logger, enabled: false); + var nextCalled = false; + + // Act + await filter.ConsumeAsync(new StubMessageContext(), _ => + { + nextCalled = true; + return Task.CompletedTask; + }); + + // Assert + nextCalled.ShouldBeTrue(); + logger.Records.ShouldBeEmpty(); + } + + [Fact] + public async Task EnabledFilterLogsConsumingAndConsumedOnSuccess() + { + // Arrange + var logger = new RecordingLogger>(); + var filter = CreateFilter(logger, enabled: true); + + // Act + await filter.ConsumeAsync( + new StubMessageContext { MessageId = "m-1", CorrelationId = "c-1" }, + _ => Task.CompletedTask); + + // Assert + logger.Records.Count.ShouldBe(2); + logger.Records[0].Level.ShouldBe(LogLevel.Debug); + logger.Records[0].Message.ShouldContain("Consuming"); + logger.Records[1].Level.ShouldBe(LogLevel.Debug); + logger.Records[1].Message.ShouldContain("Consumed"); + } + + [Fact] + public async Task EnabledFilterLogsWarningAndRethrowsOnException() + { + // Arrange + var logger = new RecordingLogger>(); + var filter = CreateFilter(logger, enabled: true); + var boom = new InvalidOperationException("boom"); + + // Act + var thrown = await Should.ThrowAsync( + () => filter.ConsumeAsync(new StubMessageContext(), _ => throw boom)); + + // Assert + thrown.ShouldBe(boom); + logger.Records.Count.ShouldBe(2); + logger.Records[0].Level.ShouldBe(LogLevel.Debug); // Consuming + logger.Records[1].Level.ShouldBe(LogLevel.Warning); // ConsumeFailed + logger.Records[1].Exception.ShouldBe(boom); + } +} From 139c82b9e2bbf1ab40b8cd965885f0d7898b59f5 Mon Sep 17 00:00:00 2001 From: Vulthil Date: Mon, 25 May 2026 20:33:09 +0200 Subject: [PATCH 05/42] refactor(messaging): merge options/provider behind dual interfaces and gate default filters at registration --- .github/copilot-instructions.md | 12 +++ src/Vulthil.Messaging/DependencyInjection.cs | 28 ++++--- .../Filters/LoggingConsumeFilter.cs | 15 +--- .../IMessageConfigurationProvider.cs | 12 ++- .../IMessagingConfigurator.cs | 6 +- .../IMessagingOptionsConfigurator.cs | 36 +++++++++ .../MessageConfigurationProvider.cs | 21 ------ .../MessagingConfigurator.cs | 4 +- src/Vulthil.Messaging/MessagingOptions.cs | 47 ++++-------- src/Vulthil.Messaging/PublicAPI.Unshipped.txt | 31 ++++---- .../Vulthil.Messaging.csproj | 1 - .../ConsumeFilterPipelineTests.cs | 4 +- .../MessageTypeCacheTests.cs | 8 +- .../Filters/DefaultFilterRegistrationTests.cs | 73 +++++++++++++++++++ .../Filters/LoggingConsumeFilterTests.cs | 38 ++-------- 15 files changed, 196 insertions(+), 140 deletions(-) create mode 100644 src/Vulthil.Messaging/IMessagingOptionsConfigurator.cs delete mode 100644 src/Vulthil.Messaging/MessageConfigurationProvider.cs create mode 100644 tests/Vulthil.Messaging.Tests/Filters/DefaultFilterRegistrationTests.cs diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 71956ff..28dd536 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,10 +1,22 @@ # Copilot Instructions +## Git Guidelines +- Do not commit without an explicit permission. Allow the developers a change to review the code to provide feedback before commiting. +- Do not create commit descriptions. Only suggest the one line commit message that describes the change. + ## Project Guidelines - Use a lazy `Target` pattern from `CreateInstance` when inheriting from `BaseUnitTestCase` (or `BaseUnitTestCase` when accessibility allows) for test classes in this repository. - When modifying a public member, make sure to update the XML Documentation and the corresponding docs in the docs folder and the README file if applicable. - When modifying a public member, make sure to check the Public.API files for the affected assembly and update them if necessary. - Do not ignore CS1591 warnings; analyze and add missing XML comments instead. +## Testing Guidelines +- Prefer using the Vulthil.xUnit testing framework for tests. +- When writing tests, follow the Arrange-Act-Assert pattern for better readability and maintainability. +- Prefer using the BaseUnitTestCase or BaseUnitTestCase classes for test cases to leverage common setup and utilities. +- Prefer using the AutoMocker instance for dependency injection in tests to simplify test setup and improve readability. +- Use the methods on the BaseUnitTestCase class for modifying the AutoMocker instance, such as `Use(T instance)` or `Use()` for registering dependencies, and `GetMock()` for retrieving mocks from the AutoMocker. +- Override the CreateInstance or CreateInstance methods and use the Target property to lazily create the instance under test. + ## Documentation Guidelines - When generating package README files, keep them short and concise, and use the docs folder for more elaborate usage patterns. \ No newline at end of file diff --git a/src/Vulthil.Messaging/DependencyInjection.cs b/src/Vulthil.Messaging/DependencyInjection.cs index b681440..5e4b433 100644 --- a/src/Vulthil.Messaging/DependencyInjection.cs +++ b/src/Vulthil.Messaging/DependencyInjection.cs @@ -2,7 +2,6 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Options; using Vulthil.Messaging.Abstractions.Consumers; using Vulthil.Messaging.Filters; using Vulthil.Messaging.Queues; @@ -37,23 +36,30 @@ public static IServiceCollection AddMessaging(this IHostApplicationBuilder build builder.Services.AddHostedService(); - // Default consume filters register first so they sit outermost; user-added filters - // compose inside. Each default filter checks its own flag on ConsumeFilterOptions at - // invocation time, so toggling the flag in code or appsettings disables the work - // without unregistering the filter. - builder.Services.TryAddEnumerable(new ServiceDescriptor( - typeof(IConsumeFilter<>), - typeof(LoggingConsumeFilter<>), - ServiceLifetime.Scoped)); - var messagingConfigurator = new MessagingConfigurator(builder, messagingOptions); messagingConfiguratorAction(messagingConfigurator); - builder.Services.AddSingleton(Options.Create(messagingOptions)); + // Default consume filters register AFTER the configurator action so users can toggle + // them via ConfigureMessagingOptions. A disabled filter is not added to DI at all — + // there is no runtime IsEnabled check. We insert at index 0 so the default open-generic + // descriptor comes first in IEnumerable> resolution, keeping it + // outermost in the composed pipeline even though chronologically it registers last. + RegisterDefaultConsumeFilters(builder.Services, messagingOptions); return builder.Services; } + private static void RegisterDefaultConsumeFilters(IServiceCollection services, MessagingOptions options) + { + if (options.ConsumeFilters.EnableLogging) + { + services.Insert(0, new ServiceDescriptor( + typeof(IConsumeFilter<>), + typeof(LoggingConsumeFilter<>), + ServiceLifetime.Scoped)); + } + } + private static void LoadQueueDefinitionsFromConfiguration(IConfiguration configuration, MessagingOptions options) { var queuesSection = configuration.GetSection($"{MessagingConfigurator.DefaultSectionName}:Queues"); diff --git a/src/Vulthil.Messaging/Filters/LoggingConsumeFilter.cs b/src/Vulthil.Messaging/Filters/LoggingConsumeFilter.cs index 38b890f..0011637 100644 --- a/src/Vulthil.Messaging/Filters/LoggingConsumeFilter.cs +++ b/src/Vulthil.Messaging/Filters/LoggingConsumeFilter.cs @@ -1,6 +1,5 @@ using System.Diagnostics; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; using Vulthil.Messaging.Abstractions.Consumers; namespace Vulthil.Messaging.Filters; @@ -8,26 +7,18 @@ namespace Vulthil.Messaging.Filters; /// /// Default open-generic consume filter that emits structured Debug logs on consume entry/exit /// and a Warning log on uncaught exceptions, with timing information. Registered as the -/// outermost filter by AddMessaging. Toggle via -/// . +/// outermost filter by AddMessaging when +/// is (the default); otherwise it is not registered at all. /// /// The consumed message type. internal sealed class LoggingConsumeFilter( - ILogger> logger, - IOptions options) : IConsumeFilter + ILogger> logger) : IConsumeFilter where TMessage : notnull { private readonly ILogger> _logger = logger; - private readonly bool _enabled = options.Value.ConsumeFilters.EnableLogging; public async Task ConsumeAsync(IMessageContext context, ConsumeDelegate next) { - if (!_enabled) - { - await next(context); - return; - } - var messageType = typeof(TMessage).FullName ?? typeof(TMessage).Name; FilterLog.Consuming(_logger, messageType, context.MessageId, context.CorrelationId); diff --git a/src/Vulthil.Messaging/IMessageConfigurationProvider.cs b/src/Vulthil.Messaging/IMessageConfigurationProvider.cs index a6d6193..a0d0c1d 100644 --- a/src/Vulthil.Messaging/IMessageConfigurationProvider.cs +++ b/src/Vulthil.Messaging/IMessageConfigurationProvider.cs @@ -4,8 +4,8 @@ namespace Vulthil.Messaging; /// -/// Provides access to message configuration and a few transport-friendly helpers. -/// Implementations live in the messaging assembly and may call internal APIs. +/// Read-only view of the messaging options resolved at runtime by transports, consumers, and filters. +/// Backed by the same instance that writes to during composition. /// public interface IMessageConfigurationProvider { @@ -15,6 +15,7 @@ public interface IMessageConfigurationProvider /// The message CLR type. /// The resolved instance. MessageConfiguration GetMessageConfiguration(Type messageType); + /// /// Gets the message configuration for the specified generic message type. /// @@ -42,4 +43,11 @@ public interface IMessageConfigurationProvider /// Returned as a snapshot; subsequent mutations to the underlying store are not reflected. /// IReadOnlyCollection QueueDefinitions { get; } + + /// + /// Gets the options controlling which built-in consume filters perform their work at runtime. + /// Filters check the appropriate flag on this object on every delivery, so toggles take effect + /// without re-registering the filter. + /// + ConsumeFilterOptions ConsumeFilters { get; } } diff --git a/src/Vulthil.Messaging/IMessagingConfigurator.cs b/src/Vulthil.Messaging/IMessagingConfigurator.cs index dd4acfa..ee4fd72 100644 --- a/src/Vulthil.Messaging/IMessagingConfigurator.cs +++ b/src/Vulthil.Messaging/IMessagingConfigurator.cs @@ -30,11 +30,11 @@ IMessagingConfigurator ConfigureMessage(Action - /// Configures global messaging options such as serialization and timeouts. + /// Configures global messaging options such as serialization, timeouts, and built-in filters. /// - /// An action to configure the messaging options. + /// An action that receives the writable options surface. /// The current configurator instance for chaining. - IMessagingConfigurator ConfigureMessagingOptions(Action action); + IMessagingConfigurator ConfigureMessagingOptions(Action action); /// /// Registers a closed-generic consume filter. The filter is applied to every delivery whose diff --git a/src/Vulthil.Messaging/IMessagingOptionsConfigurator.cs b/src/Vulthil.Messaging/IMessagingOptionsConfigurator.cs new file mode 100644 index 0000000..23d95a0 --- /dev/null +++ b/src/Vulthil.Messaging/IMessagingOptionsConfigurator.cs @@ -0,0 +1,36 @@ +using System.Text.Json; + +namespace Vulthil.Messaging; + +/// +/// Mutable view of the messaging options used during composition. Passed to the action +/// of . +/// +/// +/// The same underlying instance is exposed read-only through +/// at runtime, so changes made here are observable by transports, consumers, and filters once +/// AddMessaging returns. +/// +public interface IMessagingOptionsConfigurator +{ + /// + /// Gets or sets the JSON serializer options used for message serialization and deserialization. + /// + JsonSerializerOptions JsonSerializerOptions { get; set; } + + /// + /// Gets or sets the default timeout for request/reply operations. Default is 30 seconds. + /// + TimeSpan DefaultTimeout { get; set; } + + /// + /// Gets or sets the name of the exchange to which faults are published when a consumed message + /// carries a FaultAddress header. Default is "Fault.Exchange". + /// + string FaultExchangeName { get; set; } + + /// + /// Gets the options that control which built-in consume filters are active. + /// + ConsumeFilterOptions ConsumeFilters { get; } +} diff --git a/src/Vulthil.Messaging/MessageConfigurationProvider.cs b/src/Vulthil.Messaging/MessageConfigurationProvider.cs deleted file mode 100644 index ca27dae..0000000 --- a/src/Vulthil.Messaging/MessageConfigurationProvider.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System.Text.Json; -using Vulthil.Messaging.Queues; - -namespace Vulthil.Messaging; - -internal sealed class MessageConfigurationProvider(MessagingOptions options) : IMessageConfigurationProvider -{ - private readonly MessagingOptions _options = options; - - public MessageConfiguration GetMessageConfiguration(Type messageType) - => _options.GetMessageConfiguration(messageType); - - public MessageConfiguration GetMessageConfiguration() where TMessage : class - => _options.GetMessageConfiguration(); - - public JsonSerializerOptions JsonSerializerOptions => _options.JsonSerializerOptions; - public TimeSpan DefaultTimeout => _options.DefaultTimeout; - public string FaultExchangeName => _options.FaultExchangeName; - - public IReadOnlyCollection QueueDefinitions => _options.QueueDefinitions.Values; -} diff --git a/src/Vulthil.Messaging/MessagingConfigurator.cs b/src/Vulthil.Messaging/MessagingConfigurator.cs index a762d9c..ac7f47d 100644 --- a/src/Vulthil.Messaging/MessagingConfigurator.cs +++ b/src/Vulthil.Messaging/MessagingConfigurator.cs @@ -23,10 +23,10 @@ public MessagingConfigurator(IHostApplicationBuilder hostApplicationBuilder, Mes { HostApplicationBuilder = hostApplicationBuilder; _messagingOptions = messagingOptions; - Services.AddSingleton(_ => new MessageConfigurationProvider(_messagingOptions)); + Services.AddSingleton(_messagingOptions); } - public IMessagingConfigurator ConfigureMessagingOptions(Action action) + public IMessagingConfigurator ConfigureMessagingOptions(Action action) { action(_messagingOptions); return this; diff --git a/src/Vulthil.Messaging/MessagingOptions.cs b/src/Vulthil.Messaging/MessagingOptions.cs index 105fa2a..509fb66 100644 --- a/src/Vulthil.Messaging/MessagingOptions.cs +++ b/src/Vulthil.Messaging/MessagingOptions.cs @@ -4,49 +4,30 @@ namespace Vulthil.Messaging; /// -/// Global messaging configuration options including serialization, timeouts, and fault handling. +/// Backing store for the messaging configuration. Implements +/// for the write-side surface exposed during composition and +/// for the read-side surface consumed at runtime by transports, consumers, and filters. /// -public sealed class MessagingOptions +internal sealed class MessagingOptions : IMessagingOptionsConfigurator, IMessageConfigurationProvider { - /// - /// The configuration section name used for binding messaging options. - /// public const string SectionName = "Messaging:Options"; - /// - /// Gets or sets the JSON serializer options used for message serialization and deserialization. - /// - public JsonSerializerOptions JsonSerializerOptions { get; set; } = new(); - private readonly HashSet _registeredRequestTypes = []; + public JsonSerializerOptions JsonSerializerOptions { get; set; } = new(); - /// - /// Gets or sets the default timeout for request/reply operations. Default is 30 seconds. - /// public TimeSpan DefaultTimeout { get; set; } = TimeSpan.FromSeconds(30); - /// - /// Gets or sets the name of the exchange to which faults are published when a consumed message carries a FaultAddress header. - /// Default is "Fault.Exchange". - /// + public string FaultExchangeName { get; set; } = "Fault.Exchange"; - /// - /// Gets the options that control which built-in consume filters are active. - /// public ConsumeFilterOptions ConsumeFilters { get; } = new(); - /// - /// Message configurations keyed by the CLR full type name. Populated eagerly from Messaging:Messages:* - /// at AddMessaging time, then merged with whatever ConfigureMessage<T> registers in code. - /// + private readonly HashSet _registeredRequestTypes = []; + internal Dictionary MessageConfigurations { get; } = new(StringComparer.Ordinal); - /// - /// Queue definitions keyed by queue name. Populated eagerly from Messaging:Queues:* at AddMessaging - /// time, then merged with whatever ConfigureQueue registers in code. - /// internal Dictionary QueueDefinitions { get; } = new(StringComparer.OrdinalIgnoreCase); - internal MessageConfiguration GetMessageConfiguration(Type messageType) + /// + public MessageConfiguration GetMessageConfiguration(Type messageType) { var current = messageType; while (current != null && current != typeof(object)) @@ -62,8 +43,12 @@ internal MessageConfiguration GetMessageConfiguration(Type messageType) return new MessageConfiguration(messageType.FullName!); } - internal MessageConfiguration GetMessageConfiguration() => - GetMessageConfiguration(typeof(TMessage)); + /// + public MessageConfiguration GetMessageConfiguration() where TMessage : class + => GetMessageConfiguration(typeof(TMessage)); + + /// + IReadOnlyCollection IMessageConfigurationProvider.QueueDefinitions => QueueDefinitions.Values; internal bool RegisterRequestType(MessageType messageType) => _registeredRequestTypes.Add(messageType); } diff --git a/src/Vulthil.Messaging/PublicAPI.Unshipped.txt b/src/Vulthil.Messaging/PublicAPI.Unshipped.txt index a5c75d4..a9f343a 100644 --- a/src/Vulthil.Messaging/PublicAPI.Unshipped.txt +++ b/src/Vulthil.Messaging/PublicAPI.Unshipped.txt @@ -1,22 +1,33 @@ #nullable enable -const Vulthil.Messaging.MessagingOptions.SectionName = "Messaging:Options" -> string! Vulthil.Messaging.ConsumeFilterOptions Vulthil.Messaging.ConsumeFilterOptions.ConsumeFilterOptions() -> void Vulthil.Messaging.ConsumeFilterOptions.EnableLogging.get -> bool Vulthil.Messaging.ConsumeFilterOptions.EnableLogging.set -> void -Vulthil.Messaging.MessagingOptions.ConsumeFilters.get -> Vulthil.Messaging.ConsumeFilterOptions! static Vulthil.Messaging.DependencyInjection.AddMessaging(this Microsoft.Extensions.Hosting.IHostApplicationBuilder! builder, System.Action! messagingConfiguratorAction) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! Vulthil.Messaging.DependencyInjection +Vulthil.Messaging.IMessageConfigurationProvider +Vulthil.Messaging.IMessageConfigurationProvider.ConsumeFilters.get -> Vulthil.Messaging.ConsumeFilterOptions! +Vulthil.Messaging.IMessageConfigurationProvider.DefaultTimeout.get -> System.TimeSpan Vulthil.Messaging.IMessageConfigurationProvider.FaultExchangeName.get -> string! Vulthil.Messaging.IMessageConfigurationProvider.GetMessageConfiguration(System.Type! messageType) -> Vulthil.Messaging.MessageConfiguration! Vulthil.Messaging.IMessageConfigurationProvider.GetMessageConfiguration() -> Vulthil.Messaging.MessageConfiguration! +Vulthil.Messaging.IMessageConfigurationProvider.JsonSerializerOptions.get -> System.Text.Json.JsonSerializerOptions! +Vulthil.Messaging.IMessageConfigurationProvider.QueueDefinitions.get -> System.Collections.Generic.IReadOnlyCollection! Vulthil.Messaging.IMessagingConfigurator -Vulthil.Messaging.IMessagingConfigurator.ConfigureMessage(System.Action!>! configureMessageAction) -> Vulthil.Messaging.IMessagingConfigurator! -Vulthil.Messaging.IMessagingConfigurator.ConfigureMessagingOptions(System.Action! action) -> Vulthil.Messaging.IMessagingConfigurator! Vulthil.Messaging.IMessagingConfigurator.AddConsumeFilter() -> Vulthil.Messaging.IMessagingConfigurator! Vulthil.Messaging.IMessagingConfigurator.AddOpenConsumeFilter(System.Type! openFilterType) -> Vulthil.Messaging.IMessagingConfigurator! +Vulthil.Messaging.IMessagingConfigurator.ConfigureMessage(System.Action!>! configureMessageAction) -> Vulthil.Messaging.IMessagingConfigurator! +Vulthil.Messaging.IMessagingConfigurator.ConfigureMessagingOptions(System.Action! action) -> Vulthil.Messaging.IMessagingConfigurator! Vulthil.Messaging.IMessagingConfigurator.ConfigureQueue(string! queueName, System.Action! queueConfigurationAction) -> Vulthil.Messaging.IMessagingConfigurator! Vulthil.Messaging.IMessagingConfigurator.HostApplicationBuilder.get -> Microsoft.Extensions.Hosting.IHostApplicationBuilder! +Vulthil.Messaging.IMessagingOptionsConfigurator +Vulthil.Messaging.IMessagingOptionsConfigurator.ConsumeFilters.get -> Vulthil.Messaging.ConsumeFilterOptions! +Vulthil.Messaging.IMessagingOptionsConfigurator.DefaultTimeout.get -> System.TimeSpan +Vulthil.Messaging.IMessagingOptionsConfigurator.DefaultTimeout.set -> void +Vulthil.Messaging.IMessagingOptionsConfigurator.FaultExchangeName.get -> string! +Vulthil.Messaging.IMessagingOptionsConfigurator.FaultExchangeName.set -> void +Vulthil.Messaging.IMessagingOptionsConfigurator.JsonSerializerOptions.get -> System.Text.Json.JsonSerializerOptions! +Vulthil.Messaging.IMessagingOptionsConfigurator.JsonSerializerOptions.set -> void Vulthil.Messaging.ITransport Vulthil.Messaging.ITransport.StartAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! Vulthil.Messaging.MessageConfiguration @@ -44,14 +55,6 @@ Vulthil.Messaging.MessagingExchangeType.Direct = 1 -> Vulthil.Messaging.Messagin Vulthil.Messaging.MessagingExchangeType.Fanout = 0 -> Vulthil.Messaging.MessagingExchangeType Vulthil.Messaging.MessagingExchangeType.Headers = 3 -> Vulthil.Messaging.MessagingExchangeType Vulthil.Messaging.MessagingExchangeType.Topic = 2 -> Vulthil.Messaging.MessagingExchangeType -Vulthil.Messaging.MessagingOptions -Vulthil.Messaging.MessagingOptions.DefaultTimeout.get -> System.TimeSpan -Vulthil.Messaging.MessagingOptions.DefaultTimeout.set -> void -Vulthil.Messaging.MessagingOptions.FaultExchangeName.get -> string! -Vulthil.Messaging.MessagingOptions.FaultExchangeName.set -> void -Vulthil.Messaging.MessagingOptions.JsonSerializerOptions.get -> System.Text.Json.JsonSerializerOptions! -Vulthil.Messaging.MessagingOptions.JsonSerializerOptions.set -> void -Vulthil.Messaging.MessagingOptions.MessagingOptions() -> void Vulthil.Messaging.Queues.BaseConfigurator Vulthil.Messaging.Queues.BaseConfigurator.BaseConfigurator() -> void Vulthil.Messaging.Queues.BaseConfigurator.UseRetry(System.Action! value) -> void @@ -153,8 +156,4 @@ Vulthil.Messaging.Queues.RetryPolicyDefinition.Intervals.get -> System.Collectio Vulthil.Messaging.Queues.RetryPolicyDefinition.JitterFactor.get -> double Vulthil.Messaging.Queues.RetryPolicyDefinition.JitterFactor.set -> void Vulthil.Messaging.Queues.RetryPolicyDefinition.MaxRetryCount.get -> int -Vulthil.Messaging.IMessageConfigurationProvider -Vulthil.Messaging.IMessageConfigurationProvider.JsonSerializerOptions.get -> System.Text.Json.JsonSerializerOptions! -Vulthil.Messaging.IMessageConfigurationProvider.DefaultTimeout.get -> System.TimeSpan -Vulthil.Messaging.IMessageConfigurationProvider.QueueDefinitions.get -> System.Collections.Generic.IReadOnlyCollection! Vulthil.Messaging.Queues.RetryPolicyDefinition.MaxRetryCount.set -> void diff --git a/src/Vulthil.Messaging/Vulthil.Messaging.csproj b/src/Vulthil.Messaging/Vulthil.Messaging.csproj index f14a6ff..0d97a5f 100644 --- a/src/Vulthil.Messaging/Vulthil.Messaging.csproj +++ b/src/Vulthil.Messaging/Vulthil.Messaging.csproj @@ -17,7 +17,6 @@ - diff --git a/tests/Vulthil.Messaging.RabbitMq.Tests/ConsumeFilterPipelineTests.cs b/tests/Vulthil.Messaging.RabbitMq.Tests/ConsumeFilterPipelineTests.cs index 20394b3..cf7e6a8 100644 --- a/tests/Vulthil.Messaging.RabbitMq.Tests/ConsumeFilterPipelineTests.cs +++ b/tests/Vulthil.Messaging.RabbitMq.Tests/ConsumeFilterPipelineTests.cs @@ -188,7 +188,7 @@ public async Task RpcPipelineComposesFiltersAroundConsumerCall() var services = new ServiceCollection(); services.AddScoped(_ => consumerInstance); services.AddSingleton(Mock.Of()); - services.AddSingleton(new MessageConfigurationProvider(new MessagingOptions())); + services.AddSingleton(new MessagingOptions()); services.AddScoped>(_ => new RecordingFilter(trace, "log")); var serviceProvider = services.BuildServiceProvider(); @@ -246,7 +246,7 @@ public async Task RpcPipelineShortCircuitProducesFailureResponse() var services = new ServiceCollection(); services.AddScoped(_ => consumerInstance); services.AddSingleton(Mock.Of()); - services.AddSingleton(new MessageConfigurationProvider(new MessagingOptions())); + services.AddSingleton(new MessagingOptions()); services.AddScoped>(_ => new RecordingFilter([], "gate") { ShortCircuit = true }); var serviceProvider = services.BuildServiceProvider(); diff --git a/tests/Vulthil.Messaging.RabbitMq.Tests/MessageTypeCacheTests.cs b/tests/Vulthil.Messaging.RabbitMq.Tests/MessageTypeCacheTests.cs index b10a288..c39f32f 100644 --- a/tests/Vulthil.Messaging.RabbitMq.Tests/MessageTypeCacheTests.cs +++ b/tests/Vulthil.Messaging.RabbitMq.Tests/MessageTypeCacheTests.cs @@ -22,12 +22,8 @@ public sealed class MessageTypeCacheTests : BaseUnitTestCase public MessageTypeCacheTests() { _lazyTarget = new Lazy(CreateInstance); - // Register a real configuration provider so the RPC invoker can resolve JsonSerializerOptions - // through the scoped resolver path instead of falling back to AutoMocker's auto-mocked default. - Use(new MessageConfigurationProvider(new MessagingOptions())); - // AutoMocker auto-mocks every requested service, including IEnumerable>, - // and its default mock enumerable yields a mocked filter whose no-op ConsumeAsync silently - // short-circuits the pipeline. Register empty arrays explicitly to opt out for tested types. + UseRealFor(); + Use>>([]); Use>>([]); _serviceProvider = AutoMocker; diff --git a/tests/Vulthil.Messaging.Tests/Filters/DefaultFilterRegistrationTests.cs b/tests/Vulthil.Messaging.Tests/Filters/DefaultFilterRegistrationTests.cs new file mode 100644 index 0000000..7b7e93a --- /dev/null +++ b/tests/Vulthil.Messaging.Tests/Filters/DefaultFilterRegistrationTests.cs @@ -0,0 +1,73 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Vulthil.Messaging.Abstractions.Consumers; +using Vulthil.Messaging.Filters; +using Vulthil.xUnit; + +namespace Vulthil.Messaging.Tests.Filters; + +public sealed class DefaultFilterRegistrationTests : BaseUnitTestCase +{ + private static HostApplicationBuilder CreateHostBuilder() => Host.CreateApplicationBuilder(); + + [Fact] + public void LoggingFilterIsRegisteredByDefault() + { + // Arrange + var builder = CreateHostBuilder(); + + // Act + builder.AddMessaging(_ => { }); + + // Assert + var descriptors = builder.Services + .Where(d => d.ServiceType == typeof(IConsumeFilter<>)) + .ToList(); + + descriptors.ShouldHaveSingleItem(); + descriptors[0].ImplementationType.ShouldBe(typeof(LoggingConsumeFilter<>)); + } + + [Fact] + public void LoggingFilterIsNotRegisteredWhenDisabledViaConfigureMessagingOptions() + { + // Arrange + var builder = CreateHostBuilder(); + + // Act + builder.AddMessaging(m => + m.ConfigureMessagingOptions(opts => opts.ConsumeFilters.EnableLogging = false)); + + // Assert + var descriptors = builder.Services + .Where(d => d.ServiceType == typeof(IConsumeFilter<>)) + .ToList(); + + descriptors.ShouldBeEmpty(); + } + + [Fact] + public void LoggingFilterRegistersBeforeUserFiltersSoItStaysOutermost() + { + // Arrange + var builder = CreateHostBuilder(); + + // Act — user adds an open-generic filter inside the configurator action. + // The default must still appear first in the IConsumeFilter<> enumerable. + builder.AddMessaging(m => m.AddOpenConsumeFilter(typeof(UserFilter<>))); + + // Assert + var descriptors = builder.Services + .Where(d => d.ServiceType == typeof(IConsumeFilter<>)) + .ToList(); + + descriptors.Count.ShouldBe(2); + descriptors[0].ImplementationType.ShouldBe(typeof(LoggingConsumeFilter<>)); + descriptors[1].ImplementationType.ShouldBe(typeof(UserFilter<>)); + } + + private sealed class UserFilter : IConsumeFilter where TMessage : notnull + { + public Task ConsumeAsync(IMessageContext context, ConsumeDelegate next) => next(context); + } +} diff --git a/tests/Vulthil.Messaging.Tests/Filters/LoggingConsumeFilterTests.cs b/tests/Vulthil.Messaging.Tests/Filters/LoggingConsumeFilterTests.cs index 17b4692..21268ca 100644 --- a/tests/Vulthil.Messaging.Tests/Filters/LoggingConsumeFilterTests.cs +++ b/tests/Vulthil.Messaging.Tests/Filters/LoggingConsumeFilterTests.cs @@ -1,5 +1,4 @@ using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; using Vulthil.Messaging.Abstractions.Consumers; using Vulthil.Messaging.Abstractions.Publishers; using Vulthil.Messaging.Filters; @@ -53,39 +52,12 @@ public void Log( => Records.Add(new LogRecord(logLevel, eventId, exception, formatter(state, exception))); } - private static LoggingConsumeFilter CreateFilter(ILogger> logger, bool enabled) - { - var options = new MessagingOptions(); - options.ConsumeFilters.EnableLogging = enabled; - return new LoggingConsumeFilter(logger, Options.Create(options)); - } - - [Fact] - public async Task DisabledFilterPassesThroughWithoutLogging() - { - // Arrange - var logger = new RecordingLogger>(); - var filter = CreateFilter(logger, enabled: false); - var nextCalled = false; - - // Act - await filter.ConsumeAsync(new StubMessageContext(), _ => - { - nextCalled = true; - return Task.CompletedTask; - }); - - // Assert - nextCalled.ShouldBeTrue(); - logger.Records.ShouldBeEmpty(); - } - [Fact] - public async Task EnabledFilterLogsConsumingAndConsumedOnSuccess() + public async Task LogsConsumingAndConsumedOnSuccess() { // Arrange var logger = new RecordingLogger>(); - var filter = CreateFilter(logger, enabled: true); + var filter = new LoggingConsumeFilter(logger); // Act await filter.ConsumeAsync( @@ -101,11 +73,11 @@ await filter.ConsumeAsync( } [Fact] - public async Task EnabledFilterLogsWarningAndRethrowsOnException() + public async Task LogsWarningAndRethrowsOnException() { // Arrange var logger = new RecordingLogger>(); - var filter = CreateFilter(logger, enabled: true); + var filter = new LoggingConsumeFilter(logger); var boom = new InvalidOperationException("boom"); // Act @@ -115,7 +87,7 @@ public async Task EnabledFilterLogsWarningAndRethrowsOnException() // Assert thrown.ShouldBe(boom); logger.Records.Count.ShouldBe(2); - logger.Records[0].Level.ShouldBe(LogLevel.Debug); // Consuming + logger.Records[0].Level.ShouldBe(LogLevel.Debug); // Consuming logger.Records[1].Level.ShouldBe(LogLevel.Warning); // ConsumeFailed logger.Records[1].Exception.ShouldBe(boom); } From 000a6411b32ed83365f8f514045e6e013890cc7f Mon Sep 17 00:00:00 2001 From: Vulthil Date: Mon, 25 May 2026 23:15:45 +0200 Subject: [PATCH 06/42] feat(messaging): add point-to-point ISendEndpoint with ctx.SendAsync --- docs/articles/messaging.md | 49 +++++ .../Consumers/Fault.cs | 33 ++++ .../Consumers/IConsumer.cs | 142 +------------- .../Consumers/IMessageContext.cs | 128 +++++++++++++ .../PublicAPI.Unshipped.txt | 7 + .../Publishers/ISendEndpoint.cs | 61 ++++++ .../Consumers/IConsumerInvoker.cs | 6 +- .../Consumers/MessageContext.cs | 85 ++++++--- .../Logging/MessagingLog.cs | 4 + .../MessagingConfiguratorExtensions.cs | 3 + .../Publishing/IInternalPublisher.cs | 10 + .../Publishing/RabbitMqPublisher.cs | 22 +++ .../Sending/NullSendEndpointProvider.cs | 19 ++ .../Sending/RabbitMqSendEndpoint.cs | 105 +++++++++++ .../Sending/RabbitMqSendEndpointProvider.cs | 47 +++++ .../ConsumeFilterPipelineTests.cs | 5 + .../MessageContextSendTests.cs | 148 +++++++++++++++ .../RabbitMqSendEndpointProviderTests.cs | 96 ++++++++++ .../RabbitMqSendEndpointTests.cs | 173 ++++++++++++++++++ .../Filters/LoggingConsumeFilterTests.cs | 2 + 20 files changed, 976 insertions(+), 169 deletions(-) create mode 100644 src/Vulthil.Messaging.Abstractions/Consumers/Fault.cs create mode 100644 src/Vulthil.Messaging.Abstractions/Consumers/IMessageContext.cs create mode 100644 src/Vulthil.Messaging.Abstractions/Publishers/ISendEndpoint.cs create mode 100644 src/Vulthil.Messaging.RabbitMq/Sending/NullSendEndpointProvider.cs create mode 100644 src/Vulthil.Messaging.RabbitMq/Sending/RabbitMqSendEndpoint.cs create mode 100644 src/Vulthil.Messaging.RabbitMq/Sending/RabbitMqSendEndpointProvider.cs create mode 100644 tests/Vulthil.Messaging.RabbitMq.Tests/MessageContextSendTests.cs create mode 100644 tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqSendEndpointProviderTests.cs create mode 100644 tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqSendEndpointTests.cs diff --git a/docs/articles/messaging.md b/docs/articles/messaging.md index 0670cb8..6d0646f 100644 --- a/docs/articles/messaging.md +++ b/docs/articles/messaging.md @@ -122,6 +122,55 @@ public sealed class OrderCreatedConsumer : IConsumer `IMessageContext.CancellationToken` exposes the delivery's cancellation token for handlers that want to observe it alongside the explicit method parameter. +## Point-to-point Send + +`IPublisher.PublishAsync` fans a message out via its per-type exchange to any number of +interested consumers. When you need to address a single, named destination — typically +a specific queue on a specific service — use `ISendEndpoint` instead. Sends route +through the broker's default exchange using the destination queue name as the routing +key; topology for that queue is owned by the receiving service and is not declared by +the sender. + +Inject `ISendEndpointProvider` and resolve an endpoint by `Uri`: + +```csharp +public sealed class OrderRouter(ISendEndpointProvider sendEndpoints) +{ + public async Task RouteAsync(PlaceOrderCommand command, CancellationToken ct) + { + var endpoint = await sendEndpoints.GetSendEndpointAsync(new Uri("queue:order-commands"), ct); + await endpoint.SendAsync(command, ct); + } +} +``` + +The default address scheme is `queue:`. Absolute `amqp://`, `amqps://`, and +`rabbitmq://` URIs are also recognized — the queue name is taken from the path. Endpoints +are cached per `Uri` by the provider for the lifetime of the bus. + +`MessageConfiguration.CorrelationIdFormatter` still applies on the send path; the +`Exchange` and `RoutingKeyFormatter` settings are intentionally ignored because the +URI is authoritative for the destination. + +### Sending from inside a consumer + +`IMessageContext` exposes `SendAsync` directly, with the same auto-propagation of +`CorrelationId`/`ConversationId`/`InitiatorId` as `PublishAsync`: + +```csharp +public sealed class OrderCreatedConsumer : IConsumer +{ + public async Task ConsumeAsync( + IMessageContext ctx, + CancellationToken cancellationToken = default) + { + await ctx.SendAsync( + new Uri("queue:fulfillment-commands"), + new FulfillOrderCommand(ctx.Message.OrderId)); + } +} +``` + ## Consume Filters Consume filters wrap the consumer invocation, allowing cross-cutting concerns diff --git a/src/Vulthil.Messaging.Abstractions/Consumers/Fault.cs b/src/Vulthil.Messaging.Abstractions/Consumers/Fault.cs new file mode 100644 index 0000000..6969bd6 --- /dev/null +++ b/src/Vulthil.Messaging.Abstractions/Consumers/Fault.cs @@ -0,0 +1,33 @@ +namespace Vulthil.Messaging.Abstractions.Consumers; + +/// +/// Represents a fault envelope containing the original message and failure details. +/// +/// The type of the original message. +public record Fault where TMessage : notnull +{ + /// + /// Gets the original message that caused the fault. + /// + public required TMessage Message { get; init; } + /// + /// Gets the exception message describing the failure. + /// + public required string ExceptionMessage { get; init; } + /// + /// Gets the stack trace of the exception, or if unavailable. + /// + public required string? StackTrace { get; init; } + /// + /// Gets the fully-qualified type name of the exception. + /// + public required string ExceptionType { get; init; } + /// + /// Gets the UTC timestamp when the fault occurred. + /// + public required DateTimeOffset FaultedAt { get; init; } + /// + /// Gets the original message context at the time of the fault. + /// + public required IMessageContext OriginalContext { get; init; } +} diff --git a/src/Vulthil.Messaging.Abstractions/Consumers/IConsumer.cs b/src/Vulthil.Messaging.Abstractions/Consumers/IConsumer.cs index d0a214c..def522e 100644 --- a/src/Vulthil.Messaging.Abstractions/Consumers/IConsumer.cs +++ b/src/Vulthil.Messaging.Abstractions/Consumers/IConsumer.cs @@ -1,11 +1,10 @@ -using Vulthil.Messaging.Abstractions.Publishers; - namespace Vulthil.Messaging.Abstractions.Consumers; /// /// Marker interface for message consumers. /// public interface IConsumer; + /// /// Defines a consumer that processes messages of type . /// @@ -20,142 +19,3 @@ public interface IConsumer : IConsumer /// A task representing the asynchronous operation. Task ConsumeAsync(IMessageContext messageContext, CancellationToken cancellationToken = default); } - -/// -/// Provides transport-level metadata for a received message. -/// -public interface IMessageContext -{ - // --- Identity & Correlation --- - /// - /// Gets the unique message identifier assigned by the transport, or if not set. - /// - string? MessageId { get; } - /// - /// Gets the request identifier used to correlate a reply back to the original request, or if not set. - /// - string? RequestId { get; } - /// - /// Gets the conversation identifier that groups related messages across services, or if not set. - /// - string? ConversationId { get; } - /// - /// Gets the correlation identifier linking this message to a business transaction, or if not set. - /// - string? CorrelationId { get; } - /// - /// Gets the identifier of the message that initiated this chain, or if not set. - /// - string? InitiatorId { get; } - - // --- Addressing --- - /// - /// Gets the address of the endpoint that sent the message, or if unknown. - /// - Uri? SourceAddress { get; } - /// - /// Gets the intended destination address for the message, or if not set. - /// - Uri? DestinationAddress { get; } - /// - /// Gets the address where replies should be sent, or if no reply is expected. - /// - Uri? ResponseAddress { get; } - /// - /// Gets the address where fault notifications should be sent, or to use the default. - /// - Uri? FaultAddress { get; } - - // --- Transport Details --- - /// - /// Gets the routing key that was used by the transport to deliver this message. - /// - string RoutingKey { get; } - /// - /// Gets the transport headers associated with the message, containing custom metadata. - /// - IDictionary Headers { get; } - - // --- Timing & Lifecycle --- - /// - /// Gets the UTC timestamp when the message was originally sent, or if not recorded. - /// - DateTimeOffset? SentTime { get; } - /// - /// Gets the UTC timestamp after which the message should be discarded, or if it does not expire. - /// - DateTimeOffset? ExpirationTime { get; } - - // --- Retry Metadata --- - /// - /// Gets the number of times this message has been retried. A value of 0 indicates the first delivery attempt. - /// - int RetryCount { get; } - /// - /// Gets a value indicating whether the broker redelivered this message (e.g., after a consumer crash). - /// - bool Redelivered { get; } - - // --- Consumer Capabilities --- - /// - /// Gets the cancellation token associated with the current delivery. Combines the transport's stop signal - /// with the consumer scope, allowing handlers to observe both. - /// - CancellationToken CancellationToken { get; } - - /// - /// Publishes a message via the underlying transport, automatically propagating correlation metadata - /// (CorrelationId, ConversationId, InitiatorId) from the incoming context to the outgoing message. - /// The caller-supplied callback runs after propagation, so it can override any auto-set value. - /// - /// The type of message to publish. - /// The message to publish. - /// Optional callback for customizing the outgoing publish context. - /// A task representing the asynchronous publish operation. - Task PublishAsync( - TMessage message, - Func? configure = null) - where TMessage : notnull; -} -/// -/// Provides transport-level metadata and the deserialized payload for a received message. -/// -/// The type of message payload. -public interface IMessageContext : IMessageContext -{ - /// - /// Gets the deserialized message payload. - /// - TMessage Message { get; } -} -/// -/// Represents a fault envelope containing the original message and failure details. -/// -/// The type of the original message. -public record Fault where TMessage : notnull -{ - /// - /// Gets the original message that caused the fault. - /// - public required TMessage Message { get; init; } - /// - /// Gets the exception message describing the failure. - /// - public required string ExceptionMessage { get; init; } - /// - /// Gets the stack trace of the exception, or if unavailable. - /// - public required string? StackTrace { get; init; } - /// - /// Gets the fully-qualified type name of the exception. - /// - public required string ExceptionType { get; init; } - /// - /// Gets the UTC timestamp when the fault occurred. - /// - public required DateTimeOffset FaultedAt { get; init; } - /// - /// Gets the original message context at the time of the fault. - /// - public required IMessageContext OriginalContext { get; init; } -} diff --git a/src/Vulthil.Messaging.Abstractions/Consumers/IMessageContext.cs b/src/Vulthil.Messaging.Abstractions/Consumers/IMessageContext.cs new file mode 100644 index 0000000..3da742d --- /dev/null +++ b/src/Vulthil.Messaging.Abstractions/Consumers/IMessageContext.cs @@ -0,0 +1,128 @@ +using Vulthil.Messaging.Abstractions.Publishers; + +namespace Vulthil.Messaging.Abstractions.Consumers; + +/// +/// Provides transport-level metadata for a received message. +/// +public interface IMessageContext +{ + // --- Identity & Correlation --- + /// + /// Gets the unique message identifier assigned by the transport, or if not set. + /// + string? MessageId { get; } + /// + /// Gets the request identifier used to correlate a reply back to the original request, or if not set. + /// + string? RequestId { get; } + /// + /// Gets the conversation identifier that groups related messages across services, or if not set. + /// + string? ConversationId { get; } + /// + /// Gets the correlation identifier linking this message to a business transaction, or if not set. + /// + string? CorrelationId { get; } + /// + /// Gets the identifier of the message that initiated this chain, or if not set. + /// + string? InitiatorId { get; } + + // --- Addressing --- + /// + /// Gets the address of the endpoint that sent the message, or if unknown. + /// + Uri? SourceAddress { get; } + /// + /// Gets the intended destination address for the message, or if not set. + /// + Uri? DestinationAddress { get; } + /// + /// Gets the address where replies should be sent, or if no reply is expected. + /// + Uri? ResponseAddress { get; } + /// + /// Gets the address where fault notifications should be sent, or to use the default. + /// + Uri? FaultAddress { get; } + + // --- Transport Details --- + /// + /// Gets the routing key that was used by the transport to deliver this message. + /// + string RoutingKey { get; } + /// + /// Gets the transport headers associated with the message, containing custom metadata. + /// + IDictionary Headers { get; } + + // --- Timing & Lifecycle --- + /// + /// Gets the UTC timestamp when the message was originally sent, or if not recorded. + /// + DateTimeOffset? SentTime { get; } + /// + /// Gets the UTC timestamp after which the message should be discarded, or if it does not expire. + /// + DateTimeOffset? ExpirationTime { get; } + + // --- Retry Metadata --- + /// + /// Gets the number of times this message has been retried. A value of 0 indicates the first delivery attempt. + /// + int RetryCount { get; } + /// + /// Gets a value indicating whether the broker redelivered this message (e.g., after a consumer crash). + /// + bool Redelivered { get; } + + // --- Consumer Capabilities --- + /// + /// Gets the cancellation token associated with the current delivery. Combines the transport's stop signal + /// with the consumer scope, allowing handlers to observe both. + /// + CancellationToken CancellationToken { get; } + + /// + /// Publishes a message via the underlying transport, automatically propagating correlation metadata + /// (CorrelationId, ConversationId, InitiatorId) from the incoming context to the outgoing message. + /// The caller-supplied callback runs after propagation, so it can override any auto-set value. + /// + /// The type of message to publish. + /// The message to publish. + /// Optional callback for customizing the outgoing publish context. + /// A task representing the asynchronous publish operation. + Task PublishAsync( + TMessage message, + Func? configure = null) + where TMessage : notnull; + + /// + /// Sends a message point-to-point to the specified destination, automatically propagating correlation metadata + /// (CorrelationId, ConversationId, InitiatorId) from the incoming context to the outgoing message. + /// The caller-supplied callback runs after propagation, so it can override any auto-set value. + /// + /// The type of message to send. + /// The destination endpoint address (e.g. queue:order-commands). + /// The message to send. + /// Optional callback for customizing the outgoing publish context. + /// A task representing the asynchronous send operation. + Task SendAsync( + Uri destinationAddress, + TMessage message, + Func? configure = null) + where TMessage : notnull; +} + +/// +/// Provides transport-level metadata and the deserialized payload for a received message. +/// +/// The type of message payload. +public interface IMessageContext : IMessageContext +{ + /// + /// Gets the deserialized message payload. + /// + TMessage Message { get; } +} diff --git a/src/Vulthil.Messaging.Abstractions/PublicAPI.Unshipped.txt b/src/Vulthil.Messaging.Abstractions/PublicAPI.Unshipped.txt index 834c42d..0a71d8c 100644 --- a/src/Vulthil.Messaging.Abstractions/PublicAPI.Unshipped.txt +++ b/src/Vulthil.Messaging.Abstractions/PublicAPI.Unshipped.txt @@ -29,6 +29,7 @@ Vulthil.Messaging.Abstractions.Consumers.IMessageContext.Headers.get -> System.C Vulthil.Messaging.Abstractions.Consumers.IMessageContext.InitiatorId.get -> string? Vulthil.Messaging.Abstractions.Consumers.IMessageContext.MessageId.get -> string? Vulthil.Messaging.Abstractions.Consumers.IMessageContext.PublishAsync(TMessage message, System.Func? configure = null) -> System.Threading.Tasks.Task! +Vulthil.Messaging.Abstractions.Consumers.IMessageContext.SendAsync(System.Uri! destinationAddress, TMessage message, System.Func? configure = null) -> System.Threading.Tasks.Task! Vulthil.Messaging.Abstractions.Consumers.IMessageContext.Redelivered.get -> bool Vulthil.Messaging.Abstractions.Consumers.IMessageContext.RequestId.get -> string? Vulthil.Messaging.Abstractions.Consumers.IMessageContext.ResponseAddress.get -> System.Uri? @@ -65,3 +66,9 @@ Vulthil.Messaging.Abstractions.Publishers.IPublisher.PublishAsync(TMes Vulthil.Messaging.Abstractions.Publishers.IRequester Vulthil.Messaging.Abstractions.Publishers.IRequester.RequestAsync(TRequest message, System.Func? configureContext = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! Vulthil.Messaging.Abstractions.Publishers.IRequester.RequestAsync(TRequest message, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task!>! +Vulthil.Messaging.Abstractions.Publishers.ISendEndpoint +Vulthil.Messaging.Abstractions.Publishers.ISendEndpoint.Address.get -> System.Uri! +Vulthil.Messaging.Abstractions.Publishers.ISendEndpoint.SendAsync(TMessage message, System.Func? configureContext = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +Vulthil.Messaging.Abstractions.Publishers.ISendEndpoint.SendAsync(TMessage message, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +Vulthil.Messaging.Abstractions.Publishers.ISendEndpointProvider +Vulthil.Messaging.Abstractions.Publishers.ISendEndpointProvider.GetSendEndpointAsync(System.Uri! address, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask diff --git a/src/Vulthil.Messaging.Abstractions/Publishers/ISendEndpoint.cs b/src/Vulthil.Messaging.Abstractions/Publishers/ISendEndpoint.cs new file mode 100644 index 0000000..6b2583c --- /dev/null +++ b/src/Vulthil.Messaging.Abstractions/Publishers/ISendEndpoint.cs @@ -0,0 +1,61 @@ +namespace Vulthil.Messaging.Abstractions.Publishers; + +/// +/// Resolves instances for point-to-point message delivery. +/// +/// +/// Unlike (which fans out a message via its configured exchange to any +/// number of interested consumers), a send endpoint addresses a single, named destination — typically +/// a specific queue on a specific service. The destination is identified by a ; +/// the default scheme recognized by the RabbitMQ transport is queue:<name>. +/// +public interface ISendEndpointProvider +{ + /// + /// Resolves an for the supplied destination address. + /// + /// The destination address (e.g. queue:order-commands). + /// A token to observe for cancellation. + /// The endpoint bound to . Implementations may cache and share endpoints across calls. + ValueTask GetSendEndpointAsync(Uri address, CancellationToken cancellationToken = default); +} + +/// +/// Sends messages to a single, addressable destination (point-to-point delivery). +/// +/// +/// Sends are addressed directly to the destination identified by and bypass the +/// per-message-type exchange used by . Topology for the destination queue is owned +/// by the receiving service; the sender does not declare it. +/// +public interface ISendEndpoint +{ + /// + /// Gets the destination address this endpoint sends to. + /// + Uri Address { get; } + + /// + /// Sends a message to . + /// + /// The type of message to send. + /// The message to send. + /// A token to observe for cancellation. + Task SendAsync( + TMessage message, + CancellationToken cancellationToken) + where TMessage : notnull; + + /// + /// Sends a message to with optional context configuration. + /// + /// The type of message to send. + /// The message to send. + /// An optional callback for configuring the publish context (correlation, headers, etc.). + /// A token to observe for cancellation. + Task SendAsync( + TMessage message, + Func? configureContext = null, + CancellationToken cancellationToken = default) + where TMessage : notnull; +} diff --git a/src/Vulthil.Messaging.RabbitMq/Consumers/IConsumerInvoker.cs b/src/Vulthil.Messaging.RabbitMq/Consumers/IConsumerInvoker.cs index a3f3f47..af588c6 100644 --- a/src/Vulthil.Messaging.RabbitMq/Consumers/IConsumerInvoker.cs +++ b/src/Vulthil.Messaging.RabbitMq/Consumers/IConsumerInvoker.cs @@ -73,7 +73,8 @@ public async Task InvokeAsync( { var consumer = sp.GetRequiredService(); var publisher = sp.GetRequiredService(); - var context = MessageContext.CreateContext((TMessage)message, ea, publisher, ct); + var sendEndpointProvider = sp.GetRequiredService(); + var context = MessageContext.CreateContext((TMessage)message, ea, publisher, sendEndpointProvider, ct); var pipeline = ConsumePipelineFactory.Build( sp, @@ -111,8 +112,9 @@ public async Task InvokeAsync(IServiceProvider sp, object message, BasicDeliverE { var consumer = sp.GetRequiredService(); var publisher = sp.GetRequiredService(); + var sendEndpointProvider = sp.GetRequiredService(); var jsonOptions = sp.GetRequiredService().JsonSerializerOptions; - var context = MessageContext.CreateContext((TRequest)message, ea, publisher, ct); + var context = MessageContext.CreateContext((TRequest)message, ea, publisher, sendEndpointProvider, ct); MessageResult messageResult; try diff --git a/src/Vulthil.Messaging.RabbitMq/Consumers/MessageContext.cs b/src/Vulthil.Messaging.RabbitMq/Consumers/MessageContext.cs index 4b7cdde..0ba0c94 100644 --- a/src/Vulthil.Messaging.RabbitMq/Consumers/MessageContext.cs +++ b/src/Vulthil.Messaging.RabbitMq/Consumers/MessageContext.cs @@ -1,6 +1,7 @@ using RabbitMQ.Client.Events; using Vulthil.Messaging.Abstractions.Consumers; using Vulthil.Messaging.Abstractions.Publishers; +using Vulthil.Messaging.RabbitMq.Sending; namespace Vulthil.Messaging.RabbitMq.Consumers; @@ -9,6 +10,9 @@ internal record MessageContext : IMessageContext /// The publisher backing . Defaults to for snapshots. public required IPublisher Publisher { get; init; } + /// The send endpoint provider backing . Defaults to for snapshots. + public required ISendEndpointProvider SendEndpointProvider { get; init; } + /// public CancellationToken CancellationToken { get; init; } @@ -48,60 +52,87 @@ public Task PublishAsync(TMessage message, Func Publisher.PublishAsync( message, - async ctx => - { - // 1. Auto-propagate correlation metadata from the incoming context first. - if (!string.IsNullOrEmpty(CorrelationId)) - { - ctx.SetCorrelationId(CorrelationId); - } - ctx.ConversationId = ConversationId ?? (string.IsNullOrEmpty(CorrelationId) ? null : CorrelationId); - ctx.InitiatorId = MessageId; - - // 2. Caller's configure callback runs last so it can override any auto-set value. - if (configure is not null) - { - await configure(ctx); - } - }, + ctx => PropagateAndConfigureAsync(ctx, configure), CancellationToken); + /// + public async Task SendAsync( + Uri destinationAddress, + TMessage message, + Func? configure = null) + where TMessage : notnull + { + ArgumentNullException.ThrowIfNull(destinationAddress); + var endpoint = await SendEndpointProvider.GetSendEndpointAsync(destinationAddress, CancellationToken); + await endpoint.SendAsync( + message, + ctx => PropagateAndConfigureAsync(ctx, configure), + CancellationToken); + } + + private async ValueTask PropagateAndConfigureAsync(IPublishContext ctx, Func? configure) + { + // 1. Auto-propagate correlation metadata from the incoming context first. + if (!string.IsNullOrEmpty(CorrelationId)) + { + ctx.SetCorrelationId(CorrelationId); + } + ctx.ConversationId = ConversationId ?? (string.IsNullOrEmpty(CorrelationId) ? null : CorrelationId); + ctx.InitiatorId = MessageId; + + // 2. Caller's configure callback runs last so it can override any auto-set value. + if (configure is not null) + { + await configure(ctx); + } + } + /// - /// Creates a snapshot with no live publisher binding. + /// Creates a snapshot with no live transport binding. /// Used by fault publishing to capture the original context for serialization. /// public static MessageContext CreateContext(BasicDeliverEventArgs ea) => - BuildMetadata(ea, NullPublisher.Instance, cancellationToken: default); + BuildMetadata(ea, NullPublisher.Instance, NullSendEndpointProvider.Instance, cancellationToken: default); /// - /// Creates a live bound to the specified publisher and cancellation token. + /// Creates a live bound to the specified transport services and cancellation token. /// - public static MessageContext CreateContext(BasicDeliverEventArgs ea, IPublisher publisher, CancellationToken cancellationToken) => - BuildMetadata(ea, publisher, cancellationToken); + public static MessageContext CreateContext( + BasicDeliverEventArgs ea, + IPublisher publisher, + ISendEndpointProvider sendEndpointProvider, + CancellationToken cancellationToken) => + BuildMetadata(ea, publisher, sendEndpointProvider, cancellationToken); /// - /// Creates a snapshot typed with no live publisher binding. + /// Creates a snapshot typed with no live transport binding. /// public static MessageContext CreateContext(TMessage message, BasicDeliverEventArgs ea) => - BuildTypedMetadata(message, ea, NullPublisher.Instance, cancellationToken: default); + BuildTypedMetadata(message, ea, NullPublisher.Instance, NullSendEndpointProvider.Instance, cancellationToken: default); /// - /// Creates a live typed bound to the specified publisher and cancellation token. + /// Creates a live typed bound to the specified transport services and cancellation token. /// public static MessageContext CreateContext( TMessage message, BasicDeliverEventArgs ea, IPublisher publisher, + ISendEndpointProvider sendEndpointProvider, CancellationToken cancellationToken) => - BuildTypedMetadata(message, ea, publisher, cancellationToken); + BuildTypedMetadata(message, ea, publisher, sendEndpointProvider, cancellationToken); - private static MessageContext BuildMetadata(BasicDeliverEventArgs ea, IPublisher publisher, CancellationToken cancellationToken) + private static MessageContext BuildMetadata( + BasicDeliverEventArgs ea, + IPublisher publisher, + ISendEndpointProvider sendEndpointProvider, + CancellationToken cancellationToken) { var props = ea.BasicProperties; var headers = props.Headers ?? new Dictionary(); return new MessageContext { Publisher = publisher, + SendEndpointProvider = sendEndpointProvider, CancellationToken = cancellationToken, MessageId = props.MessageId, CorrelationId = props.CorrelationId ?? string.Empty, @@ -126,6 +157,7 @@ private static MessageContext BuildTypedMetadata( TMessage message, BasicDeliverEventArgs ea, IPublisher publisher, + ISendEndpointProvider sendEndpointProvider, CancellationToken cancellationToken) { var props = ea.BasicProperties; @@ -134,6 +166,7 @@ private static MessageContext BuildTypedMetadata( { Message = message, Publisher = publisher, + SendEndpointProvider = sendEndpointProvider, CancellationToken = cancellationToken, MessageId = props.MessageId, CorrelationId = props.CorrelationId ?? string.Empty, diff --git a/src/Vulthil.Messaging.RabbitMq/Logging/MessagingLog.cs b/src/Vulthil.Messaging.RabbitMq/Logging/MessagingLog.cs index 4a9e75e..4c1e947 100644 --- a/src/Vulthil.Messaging.RabbitMq/Logging/MessagingLog.cs +++ b/src/Vulthil.Messaging.RabbitMq/Logging/MessagingLog.cs @@ -52,6 +52,10 @@ internal static partial class MessagingLog Message = "Declared exchange '{Exchange}' of type {ExchangeType}")] public static partial void ExchangeDeclared(ILogger logger, string exchange, MessagingExchangeType exchangeType); + [LoggerMessage(EventId = 1202, Level = LogLevel.Debug, + Message = "Sending {MessageType} to queue '{Queue}' (messageId='{MessageId}', correlationId='{CorrelationId}')")] + public static partial void Sending(ILogger logger, string messageType, string queue, string messageId, string correlationId); + [LoggerMessage(EventId = 1300, Level = LogLevel.Debug, Message = "Sending request {RequestType} (correlationId='{CorrelationId}', timeout={TimeoutSeconds}s)")] public static partial void RequestSending(ILogger logger, string requestType, string correlationId, double timeoutSeconds); diff --git a/src/Vulthil.Messaging.RabbitMq/MessagingConfiguratorExtensions.cs b/src/Vulthil.Messaging.RabbitMq/MessagingConfiguratorExtensions.cs index 3c417f1..70d2a34 100644 --- a/src/Vulthil.Messaging.RabbitMq/MessagingConfiguratorExtensions.cs +++ b/src/Vulthil.Messaging.RabbitMq/MessagingConfiguratorExtensions.cs @@ -7,6 +7,7 @@ using Vulthil.Messaging.RabbitMq.HealthChecks; using Vulthil.Messaging.RabbitMq.Publishing; using Vulthil.Messaging.RabbitMq.Requests; +using Vulthil.Messaging.RabbitMq.Sending; using Vulthil.Messaging.RabbitMq.Telemetry; namespace Vulthil.Messaging.RabbitMq; @@ -57,6 +58,8 @@ public static IMessagingConfigurator UseRabbitMq( services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(); + // ResponseListener is lazily initialized on the first IRequester.RequestAsync call. // Services that never make request/reply calls do not pay the cost of declaring a reply queue. services.AddSingleton(); diff --git a/src/Vulthil.Messaging.RabbitMq/Publishing/IInternalPublisher.cs b/src/Vulthil.Messaging.RabbitMq/Publishing/IInternalPublisher.cs index 67955a6..bfdc770 100644 --- a/src/Vulthil.Messaging.RabbitMq/Publishing/IInternalPublisher.cs +++ b/src/Vulthil.Messaging.RabbitMq/Publishing/IInternalPublisher.cs @@ -10,4 +10,14 @@ Task InternalPublishAsync( string routingKey, MessageConfiguration messageConfiguration, CancellationToken cancellationToken); + + /// + /// Publishes a message to the broker's default exchange using the supplied queue name as the routing key. + /// No topology declaration is performed — the destination queue is owned by the receiving service. + /// + Task InternalSendAsync( + byte[] body, + BasicProperties props, + string queueName, + CancellationToken cancellationToken); } diff --git a/src/Vulthil.Messaging.RabbitMq/Publishing/RabbitMqPublisher.cs b/src/Vulthil.Messaging.RabbitMq/Publishing/RabbitMqPublisher.cs index b7f55c9..5f04ea9 100644 --- a/src/Vulthil.Messaging.RabbitMq/Publishing/RabbitMqPublisher.cs +++ b/src/Vulthil.Messaging.RabbitMq/Publishing/RabbitMqPublisher.cs @@ -43,6 +43,28 @@ public async Task InternalPublishAsync( await EnsureChannelAsync(cancellationToken); await EnsureExchangeTopologyAsync(exchange, messageConfiguration, cancellationToken); + await BasicPublishAsync(exchange, routingKey, props, body, cancellationToken); + } + + public async Task InternalSendAsync( + byte[] body, + BasicProperties props, + string queueName, + CancellationToken cancellationToken) + { + // Sends route via the broker's default exchange (always exists, always routes by queue name). + // The destination queue is owned by the receiving service, so we do not declare it here. + await EnsureChannelAsync(cancellationToken); + await BasicPublishAsync(exchange: string.Empty, routingKey: queueName, props, body, cancellationToken); + } + + private async Task BasicPublishAsync( + string exchange, + string routingKey, + BasicProperties props, + byte[] body, + CancellationToken cancellationToken) + { await _channelSemaphore.WaitAsync(cancellationToken); try diff --git a/src/Vulthil.Messaging.RabbitMq/Sending/NullSendEndpointProvider.cs b/src/Vulthil.Messaging.RabbitMq/Sending/NullSendEndpointProvider.cs new file mode 100644 index 0000000..fd414fb --- /dev/null +++ b/src/Vulthil.Messaging.RabbitMq/Sending/NullSendEndpointProvider.cs @@ -0,0 +1,19 @@ +using Vulthil.Messaging.Abstractions.Publishers; + +namespace Vulthil.Messaging.RabbitMq.Sending; + +/// +/// A placeholder used by snapshots +/// (e.g. ) where no live +/// transport is bound. Calling on a snapshot is a programmer error. +/// +internal sealed class NullSendEndpointProvider : ISendEndpointProvider +{ + public static readonly NullSendEndpointProvider Instance = new(); + + private NullSendEndpointProvider() { } + + public ValueTask GetSendEndpointAsync(Uri address, CancellationToken cancellationToken = default) + => throw new InvalidOperationException( + "This message context is a snapshot (e.g. a fault envelope) and is not bound to a live transport."); +} diff --git a/src/Vulthil.Messaging.RabbitMq/Sending/RabbitMqSendEndpoint.cs b/src/Vulthil.Messaging.RabbitMq/Sending/RabbitMqSendEndpoint.cs new file mode 100644 index 0000000..77b800c --- /dev/null +++ b/src/Vulthil.Messaging.RabbitMq/Sending/RabbitMqSendEndpoint.cs @@ -0,0 +1,105 @@ +using System.Diagnostics; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using RabbitMQ.Client; +using Vulthil.Messaging.Abstractions.Publishers; +using Vulthil.Messaging.RabbitMq.Logging; +using Vulthil.Messaging.RabbitMq.Publishing; +using Vulthil.Messaging.RabbitMq.Requests; +using Vulthil.Messaging.RabbitMq.Telemetry; + +namespace Vulthil.Messaging.RabbitMq.Sending; + +internal sealed class RabbitMqSendEndpoint : ISendEndpoint +{ + private readonly IInternalPublisher _publisher; + private readonly IMessageConfigurationProvider _messageConfigurationProvider; + private readonly ILogger _logger; + private readonly string _queueName; + + public RabbitMqSendEndpoint( + Uri address, + string queueName, + IInternalPublisher publisher, + IMessageConfigurationProvider messageConfigurationProvider, + ILogger logger) + { + Address = address; + _queueName = queueName; + _publisher = publisher; + _messageConfigurationProvider = messageConfigurationProvider; + _logger = logger; + } + + public Uri Address { get; } + + public Task SendAsync(TMessage message, CancellationToken cancellationToken) + where TMessage : notnull + => SendAsync(message, null, cancellationToken); + + public async Task SendAsync( + TMessage message, + Func? configureContext = null, + CancellationToken cancellationToken = default) + where TMessage : notnull + { + ArgumentNullException.ThrowIfNull(message); + + var publishContext = new PublishContext(); + configureContext ??= (_ => ValueTask.CompletedTask); + await configureContext(publishContext); + + var type = message.GetType(); + // MessageConfiguration.CorrelationIdFormatter still applies; Exchange and RoutingKeyFormatter + // are intentionally ignored on the send path — the destination queue name is authoritative. + var messageConfiguration = _messageConfigurationProvider.GetMessageConfiguration(type); + + var correlationId = publishContext.CorrelationId + ?? messageConfiguration.CorrelationIdFormatter?.Invoke(message) + ?? Guid.CreateVersion7().ToString(); + var messageId = publishContext.MessageId ?? Guid.CreateVersion7().ToString(); + + using var activity = MessagingInstrumentation.ActivitySource.StartActivity( + $"{_queueName} send", + ActivityKind.Producer); + + if (activity is not null) + { + activity.SetTag(MessagingInstrumentation.Tags.MessagingSystem, MessagingInstrumentation.SystemValue); + activity.SetTag(MessagingInstrumentation.Tags.MessagingOperation, "send"); + activity.SetTag(MessagingInstrumentation.Tags.MessagingDestination, _queueName); + activity.SetTag(MessagingInstrumentation.Tags.MessagingRoutingKey, _queueName); + activity.SetTag(MessagingInstrumentation.Tags.MessageType, type.FullName); + activity.SetTag(MessagingInstrumentation.Tags.MessagingMessageId, messageId); + activity.SetTag(MessagingInstrumentation.Tags.MessagingCorrelationId, correlationId); + } + + var properties = new BasicProperties + { + Type = type.FullName, + MessageId = messageId, + ReplyTo = PublishContext.ResolveRoutingKeyFromUri(publishContext.ResponseAddress), + CorrelationId = correlationId, + ContentType = RabbitMqConstants.ContentType, + Headers = publishContext.Headers, + Persistent = true, + Timestamp = new AmqpTimestamp(DateTimeOffset.UtcNow.ToUnixTimeSeconds()), + }; + + var body = JsonSerializer.SerializeToUtf8Bytes(message, _messageConfigurationProvider.JsonSerializerOptions); + + MessagingLog.Sending(_logger, type.FullName ?? type.Name, _queueName, messageId, correlationId); + + try + { + await _publisher.InternalSendAsync(body, properties, _queueName, cancellationToken); + activity?.SetStatus(ActivityStatusCode.Ok); + } + catch (Exception ex) + { + activity?.SetStatus(ActivityStatusCode.Error, ex.Message); + activity?.AddException(ex); + throw; + } + } +} diff --git a/src/Vulthil.Messaging.RabbitMq/Sending/RabbitMqSendEndpointProvider.cs b/src/Vulthil.Messaging.RabbitMq/Sending/RabbitMqSendEndpointProvider.cs new file mode 100644 index 0000000..93e86f8 --- /dev/null +++ b/src/Vulthil.Messaging.RabbitMq/Sending/RabbitMqSendEndpointProvider.cs @@ -0,0 +1,47 @@ +using System.Collections.Concurrent; +using Microsoft.Extensions.Logging; +using Vulthil.Messaging.Abstractions.Publishers; +using Vulthil.Messaging.RabbitMq.Publishing; +using Vulthil.Messaging.RabbitMq.Requests; + +namespace Vulthil.Messaging.RabbitMq.Sending; + +internal sealed class RabbitMqSendEndpointProvider : ISendEndpointProvider +{ + private readonly IInternalPublisher _publisher; + private readonly IMessageConfigurationProvider _messageConfigurationProvider; + private readonly ILogger _endpointLogger; + private readonly ConcurrentDictionary _endpoints = new(); + + public RabbitMqSendEndpointProvider( + IInternalPublisher publisher, + IMessageConfigurationProvider messageConfigurationProvider, + ILoggerFactory loggerFactory) + { + _publisher = publisher; + _messageConfigurationProvider = messageConfigurationProvider; + _endpointLogger = loggerFactory.CreateLogger(); + } + + public ValueTask GetSendEndpointAsync(Uri address, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(address); + + var endpoint = _endpoints.GetOrAdd(address, CreateEndpoint); + return ValueTask.FromResult(endpoint); + } + + private ISendEndpoint CreateEndpoint(Uri address) + { + var queueName = PublishContext.ResolveRoutingKeyFromUri(address); + if (string.IsNullOrEmpty(queueName)) + { + throw new ArgumentException( + $"Send endpoint address '{address}' did not resolve to a destination queue name. " + + "Use a 'queue:' URI or an absolute amqp/rabbitmq URI whose path identifies the queue.", + nameof(address)); + } + + return new RabbitMqSendEndpoint(address, queueName, _publisher, _messageConfigurationProvider, _endpointLogger); + } +} diff --git a/tests/Vulthil.Messaging.RabbitMq.Tests/ConsumeFilterPipelineTests.cs b/tests/Vulthil.Messaging.RabbitMq.Tests/ConsumeFilterPipelineTests.cs index cf7e6a8..de50794 100644 --- a/tests/Vulthil.Messaging.RabbitMq.Tests/ConsumeFilterPipelineTests.cs +++ b/tests/Vulthil.Messaging.RabbitMq.Tests/ConsumeFilterPipelineTests.cs @@ -94,6 +94,7 @@ public async Task PipelineWithNoFiltersInvokesConsumerDirectly() var services = new ServiceCollection(); services.AddScoped(_ => consumerInstance); services.AddSingleton(Mock.Of()); + services.AddSingleton(Mock.Of()); var serviceProvider = services.BuildServiceProvider(); var queue = new QueueDefinition("TestQueue"); @@ -123,6 +124,7 @@ public async Task PipelineComposesFiltersInRegistrationOrderOutermostFirst() var services = new ServiceCollection(); services.AddScoped(_ => consumerInstance); services.AddSingleton(Mock.Of()); + services.AddSingleton(Mock.Of()); // Order matters: First registered should be outermost. services.AddScoped>(_ => new RecordingFilter(trace, "outer")); services.AddScoped>(_ => new RecordingFilter(trace, "inner")); @@ -156,6 +158,7 @@ public async Task FilterShortCircuitPreventsConsumerInvocation() var services = new ServiceCollection(); services.AddScoped(_ => consumerInstance); services.AddSingleton(Mock.Of()); + services.AddSingleton(Mock.Of()); services.AddScoped>(_ => new RecordingFilter(trace, "gate") { ShortCircuit = true }); var serviceProvider = services.BuildServiceProvider(); @@ -188,6 +191,7 @@ public async Task RpcPipelineComposesFiltersAroundConsumerCall() var services = new ServiceCollection(); services.AddScoped(_ => consumerInstance); services.AddSingleton(Mock.Of()); + services.AddSingleton(Mock.Of()); services.AddSingleton(new MessagingOptions()); services.AddScoped>(_ => new RecordingFilter(trace, "log")); var serviceProvider = services.BuildServiceProvider(); @@ -246,6 +250,7 @@ public async Task RpcPipelineShortCircuitProducesFailureResponse() var services = new ServiceCollection(); services.AddScoped(_ => consumerInstance); services.AddSingleton(Mock.Of()); + services.AddSingleton(Mock.Of()); services.AddSingleton(new MessagingOptions()); services.AddScoped>(_ => new RecordingFilter([], "gate") { ShortCircuit = true }); diff --git a/tests/Vulthil.Messaging.RabbitMq.Tests/MessageContextSendTests.cs b/tests/Vulthil.Messaging.RabbitMq.Tests/MessageContextSendTests.cs new file mode 100644 index 0000000..7865696 --- /dev/null +++ b/tests/Vulthil.Messaging.RabbitMq.Tests/MessageContextSendTests.cs @@ -0,0 +1,148 @@ +using RabbitMQ.Client; +using RabbitMQ.Client.Events; +using Vulthil.Messaging.Abstractions.Publishers; +using Vulthil.Messaging.RabbitMq.Consumers; +using Vulthil.Messaging.RabbitMq.Requests; +using Vulthil.xUnit; + +namespace Vulthil.Messaging.RabbitMq.Tests; + +/// +/// Verifies that IMessageContext.SendAsync auto-propagates correlation metadata in the same way as PublishAsync. +/// +public sealed class MessageContextSendTests : BaseUnitTestCase +{ + private sealed record TestMessage(string Content); + + /// + /// Verifies that CorrelationId, ConversationId, and InitiatorId from the incoming context are propagated to the outgoing send. + /// + [Fact] + public async Task SendAsyncShouldPropagateCorrelationMetadata() + { + // Arrange + var endpointMock = new Mock(); + var capturedPublishContext = new PublishContext(); + endpointMock + .Setup(e => e.SendAsync( + It.IsAny(), + It.IsAny?>(), + It.IsAny())) + .Returns?, CancellationToken>( + async (_, configure, _) => + { + if (configure is not null) + { + await configure(capturedPublishContext); + } + }); + + var providerMock = new Mock(); + providerMock + .Setup(p => p.GetSendEndpointAsync(It.IsAny(), It.IsAny())) + .Returns((uri, _) => ValueTask.FromResult(endpointMock.Object)); + + var context = CreateTypedContext( + providerMock.Object, + correlationId: "corr-1", + conversationId: "conv-1", + messageId: "msg-1"); + + // Act + await context.SendAsync(new Uri("queue:dest"), new TestMessage("payload")); + + // Assert + capturedPublishContext.CorrelationId.ShouldBe("corr-1"); + capturedPublishContext.ConversationId.ShouldBe("conv-1"); + capturedPublishContext.InitiatorId.ShouldBe("msg-1"); + } + + /// + /// Verifies that an explicit configure callback overrides auto-propagated values. + /// + [Fact] + public async Task SendAsyncShouldLetExplicitConfigureOverrideAutoPropagation() + { + // Arrange + var endpointMock = new Mock(); + var capturedPublishContext = new PublishContext(); + endpointMock + .Setup(e => e.SendAsync( + It.IsAny(), + It.IsAny?>(), + It.IsAny())) + .Returns?, CancellationToken>( + async (_, configure, _) => + { + if (configure is not null) + { + await configure(capturedPublishContext); + } + }); + + var providerMock = new Mock(); + providerMock + .Setup(p => p.GetSendEndpointAsync(It.IsAny(), It.IsAny())) + .Returns((_, _) => ValueTask.FromResult(endpointMock.Object)); + + var context = CreateTypedContext(providerMock.Object, correlationId: "auto-corr"); + + // Act + await context.SendAsync( + new Uri("queue:dest"), + new TestMessage("payload"), + ctx => + { + ctx.SetCorrelationId("explicit-corr"); + return ValueTask.CompletedTask; + }); + + // Assert + capturedPublishContext.CorrelationId.ShouldBe("explicit-corr"); + } + + /// + /// Verifies that a null destination address throws ArgumentNullException. + /// + [Fact] + public async Task SendAsyncWithNullDestinationThrows() + { + // Arrange + var providerMock = new Mock(); + var context = CreateTypedContext(providerMock.Object); + + // Act & Assert + await Assert.ThrowsAsync( + () => context.SendAsync(null!, new TestMessage("x"))); + } + + private static MessageContext CreateTypedContext( + ISendEndpointProvider sendEndpointProvider, + string correlationId = "corr-1", + string? conversationId = null, + string? messageId = null) + { + var headers = new Dictionary(); + if (!string.IsNullOrEmpty(conversationId)) + { + headers["ConversationId"] = System.Text.Encoding.UTF8.GetBytes(conversationId); + } + + var props = new BasicProperties + { + CorrelationId = correlationId, + MessageId = messageId ?? "msg-id", + Headers = headers, + }; + var ea = new BasicDeliverEventArgs( + "consumer-tag", + 1, + false, + "exchange", + "routing.key", + props, + ReadOnlyMemory.Empty); + + return MessageContext.CreateContext(new TestMessage("payload"), ea, NullPublisher.Instance, sendEndpointProvider, CancellationToken.None); + } +} diff --git a/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqSendEndpointProviderTests.cs b/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqSendEndpointProviderTests.cs new file mode 100644 index 0000000..4598559 --- /dev/null +++ b/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqSendEndpointProviderTests.cs @@ -0,0 +1,96 @@ +using Microsoft.Extensions.Logging; +using Vulthil.Messaging.RabbitMq.Publishing; +using Vulthil.Messaging.RabbitMq.Sending; +using Vulthil.xUnit; + +namespace Vulthil.Messaging.RabbitMq.Tests; + +/// +/// Represents the RabbitMqSendEndpointProviderTests. +/// +public sealed class RabbitMqSendEndpointProviderTests : BaseUnitTestCase +{ + private readonly Lazy _lazyTarget; + + private RabbitMqSendEndpointProvider Target => _lazyTarget.Value; + + /// + /// Initializes test infrastructure. + /// + public RabbitMqSendEndpointProviderTests() + { + var loggerFactoryMock = GetMock(); + loggerFactoryMock.Setup(f => f.CreateLogger(It.IsAny())) + .Returns(GetMock().Object); + Use(loggerFactoryMock.Object); + Use(GetMock().Object); + Use(GetMock().Object); + _lazyTarget = new(CreateInstance); + } + + /// + /// Verifies that repeated lookups for the same Uri return the same cached endpoint instance. + /// + [Fact] + public async Task GetSendEndpointAsyncShouldCacheByUri() + { + // Arrange + var uri = new Uri("queue:my-queue"); + + // Act + var first = await Target.GetSendEndpointAsync(uri, CancellationToken); + var second = await Target.GetSendEndpointAsync(uri, CancellationToken); + + // Assert + first.ShouldBeSameAs(second); + first.Address.ShouldBe(uri); + } + + /// + /// Verifies that different Uris yield distinct endpoint instances. + /// + [Fact] + public async Task GetSendEndpointAsyncShouldReturnDistinctEndpointsForDistinctUris() + { + // Arrange + var a = new Uri("queue:queue-a"); + var b = new Uri("queue:queue-b"); + + // Act + var endpointA = await Target.GetSendEndpointAsync(a, CancellationToken); + var endpointB = await Target.GetSendEndpointAsync(b, CancellationToken); + + // Assert + endpointA.ShouldNotBeSameAs(endpointB); + endpointA.Address.ShouldBe(a); + endpointB.Address.ShouldBe(b); + } + + /// + /// Verifies that an empty queue name in the Uri throws ArgumentException. + /// + [Fact] + public async Task GetSendEndpointAsyncWithEmptyQueueNameThrows() + { + // Arrange + var uri = new Uri("queue:"); + + // Act & Assert + await Assert.ThrowsAsync( + async () => await Target.GetSendEndpointAsync(uri, CancellationToken)); + } + + /// + /// Verifies that NullSendEndpointProvider throws when asked for an endpoint. + /// + [Fact] + public async Task NullSendEndpointProviderShouldThrow() + { + // Arrange + var provider = NullSendEndpointProvider.Instance; + + // Act & Assert + await Assert.ThrowsAsync( + async () => await provider.GetSendEndpointAsync(new Uri("queue:any"), CancellationToken)); + } +} diff --git a/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqSendEndpointTests.cs b/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqSendEndpointTests.cs new file mode 100644 index 0000000..9ee41ed --- /dev/null +++ b/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqSendEndpointTests.cs @@ -0,0 +1,173 @@ +using Microsoft.Extensions.Logging; +using RabbitMQ.Client; +using Vulthil.Messaging.Abstractions.Publishers; +using Vulthil.Messaging.RabbitMq.Publishing; +using Vulthil.Messaging.RabbitMq.Sending; +using Vulthil.xUnit; + +namespace Vulthil.Messaging.RabbitMq.Tests; + +/// +/// Represents the RabbitMqSendEndpointTests. +/// +public sealed class RabbitMqSendEndpointTests : BaseUnitTestCase +{ + private const string QueueName = "order-commands"; + private static readonly Uri Address = new($"queue:{QueueName}"); + + private readonly Lazy _lazyTarget; + private readonly Mock _publisherMock; + private readonly Mock _messageConfigurationProviderMock; + + private RabbitMqSendEndpoint Target => _lazyTarget.Value; + + /// + /// Initializes test infrastructure. + /// + public RabbitMqSendEndpointTests() + { + _publisherMock = GetMock(); + _publisherMock.Setup(p => p.InternalSendAsync( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + _messageConfigurationProviderMock = GetMock(); + _messageConfigurationProviderMock.Setup(p => p.GetMessageConfiguration(It.IsAny())) + .Returns(t => new MessageConfiguration(t.FullName!)); + _messageConfigurationProviderMock.SetupGet(p => p.JsonSerializerOptions) + .Returns(new System.Text.Json.JsonSerializerOptions()); + + var logger = GetMock>().Object; + + _lazyTarget = new(() => new RabbitMqSendEndpoint( + Address, + QueueName, + _publisherMock.Object, + _messageConfigurationProviderMock.Object, + logger)); + } + + /// + /// Verifies that the send path routes via the destination queue name and ignores the per-type exchange. + /// + [Fact] + public async Task SendAsyncShouldRouteToDestinationQueueByName() + { + // Arrange + var message = new TestMessage { Content = "send-me" }; + string? capturedQueue = null; + _publisherMock.Setup(p => p.InternalSendAsync( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((byte[] _, BasicProperties _, string queue, CancellationToken _) => capturedQueue = queue) + .Returns(Task.CompletedTask); + + // Act + await Target.SendAsync(message, CancellationToken); + + // Assert + capturedQueue.ShouldBe(QueueName); + } + + /// + /// Verifies that the CorrelationIdFormatter on MessageConfiguration is applied when no explicit value is provided. + /// + [Fact] + public async Task SendAsyncShouldApplyCorrelationIdFormatter() + { + // Arrange + var message = new TestMessage { Content = "abc" }; + BasicProperties? captured = null; + _publisherMock.Setup(p => p.InternalSendAsync( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((byte[] _, BasicProperties props, string _, CancellationToken _) => captured = props) + .Returns(Task.CompletedTask); + + _messageConfigurationProviderMock.Setup(p => p.GetMessageConfiguration(It.IsAny())) + .Returns(t => new MessageConfiguration(t.FullName!) + { + CorrelationIdFormatter = m => "correlation-from-formatter" + }); + + // Act + await Target.SendAsync(message, CancellationToken); + + // Assert + captured.ShouldNotBeNull(); + captured.CorrelationId.ShouldBe("correlation-from-formatter"); + } + + /// + /// Verifies that an explicit configureContext value wins over the formatter. + /// + [Fact] + public async Task SendAsyncShouldPreferExplicitCorrelationIdOverFormatter() + { + // Arrange + var message = new TestMessage { Content = "abc" }; + BasicProperties? captured = null; + _publisherMock.Setup(p => p.InternalSendAsync( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((byte[] _, BasicProperties props, string _, CancellationToken _) => captured = props) + .Returns(Task.CompletedTask); + + _messageConfigurationProviderMock.Setup(p => p.GetMessageConfiguration(It.IsAny())) + .Returns(t => new MessageConfiguration(t.FullName!) + { + CorrelationIdFormatter = m => "from-formatter" + }); + + // Act + await Target.SendAsync( + message, + ctx => + { + ctx.SetCorrelationId("explicit"); + return ValueTask.CompletedTask; + }, + CancellationToken); + + // Assert + captured.ShouldNotBeNull(); + captured.CorrelationId.ShouldBe("explicit"); + } + + /// + /// Verifies that a null message throws ArgumentNullException. + /// + [Fact] + public async Task SendAsyncWithNullMessageThrows() + { + // Act & Assert + await Assert.ThrowsAsync( + () => Target.SendAsync(null!, CancellationToken)); + } + + /// + /// Verifies that BasicProperties carries the CLR type name and the persistent flag. + /// + [Fact] + public async Task SendAsyncShouldPopulateBasicPropertiesType() + { + // Arrange + var message = new TestMessage { Content = "x" }; + BasicProperties? captured = null; + _publisherMock.Setup(p => p.InternalSendAsync( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((byte[] _, BasicProperties props, string _, CancellationToken _) => captured = props) + .Returns(Task.CompletedTask); + + // Act + await Target.SendAsync(message, CancellationToken); + + // Assert + captured.ShouldNotBeNull(); + captured.Type.ShouldBe(typeof(TestMessage).FullName); + captured.Persistent.ShouldBeTrue(); + } + + private sealed class TestMessage + { + /// Gets or sets the content. + public string Content { get; set; } = string.Empty; + } +} diff --git a/tests/Vulthil.Messaging.Tests/Filters/LoggingConsumeFilterTests.cs b/tests/Vulthil.Messaging.Tests/Filters/LoggingConsumeFilterTests.cs index 21268ca..d6f6d86 100644 --- a/tests/Vulthil.Messaging.Tests/Filters/LoggingConsumeFilterTests.cs +++ b/tests/Vulthil.Messaging.Tests/Filters/LoggingConsumeFilterTests.cs @@ -31,6 +31,8 @@ private sealed class StubMessageContext : IMessageContext public CancellationToken CancellationToken { get; init; } public Task PublishAsync(TMsg message, Func? configure = null) where TMsg : notnull => Task.CompletedTask; + public Task SendAsync(Uri destinationAddress, TMsg message, Func? configure = null) where TMsg : notnull + => Task.CompletedTask; } private sealed record LogRecord(LogLevel Level, EventId EventId, Exception? Exception, string Message); From 411235318e44bddfbdb879d6d62612e2316266d0 Mon Sep 17 00:00:00 2001 From: Vulthil Date: Tue, 26 May 2026 21:43:56 +0200 Subject: [PATCH 07/42] refactor(messaging): collapse consumer invokers into MessageHandler + typed factory --- .../Consumers/ConsumePipelineFactory.cs | 35 ++++ .../Consumers/HandlerKind.cs | 14 ++ .../Consumers/IConsumerInvoker.cs | 171 ------------------ .../Consumers/MessageExecutionPlan.cs | 12 ++ .../Consumers/MessageHandler.cs | 29 +++ .../Consumers/MessageHandlerFactory.cs | 118 ++++++++++++ .../Consumers/MessageTypeCache.cs | 66 ++++--- .../Consumers/RabbitMqConsumerWorker.cs | 13 +- .../ConsumeFilterPipelineTests.cs | 26 +-- .../MessageTypeCacheTests.cs | 75 +++++--- 10 files changed, 315 insertions(+), 244 deletions(-) create mode 100644 src/Vulthil.Messaging.RabbitMq/Consumers/ConsumePipelineFactory.cs create mode 100644 src/Vulthil.Messaging.RabbitMq/Consumers/HandlerKind.cs delete mode 100644 src/Vulthil.Messaging.RabbitMq/Consumers/IConsumerInvoker.cs create mode 100644 src/Vulthil.Messaging.RabbitMq/Consumers/MessageExecutionPlan.cs create mode 100644 src/Vulthil.Messaging.RabbitMq/Consumers/MessageHandler.cs create mode 100644 src/Vulthil.Messaging.RabbitMq/Consumers/MessageHandlerFactory.cs diff --git a/src/Vulthil.Messaging.RabbitMq/Consumers/ConsumePipelineFactory.cs b/src/Vulthil.Messaging.RabbitMq/Consumers/ConsumePipelineFactory.cs new file mode 100644 index 0000000..ddd47e6 --- /dev/null +++ b/src/Vulthil.Messaging.RabbitMq/Consumers/ConsumePipelineFactory.cs @@ -0,0 +1,35 @@ +using Microsoft.Extensions.DependencyInjection; +using Vulthil.Messaging.Abstractions.Consumers; + +namespace Vulthil.Messaging.RabbitMq.Consumers; + +internal static class ConsumePipelineFactory +{ + /// + /// Composes the registered instances around a terminal + /// delegate. The first filter resolved from DI becomes the outermost; the terminal delegate + /// runs innermost. + /// + public static ConsumeDelegate Build( + IServiceProvider sp, + ConsumeDelegate terminal) + where TMessage : notnull + { + var filters = sp.GetServices>().ToArray(); + if (filters.Length == 0) + { + return terminal; + } + + var pipeline = terminal; + // Iterate in reverse so the first-registered filter ends up outermost. + for (var i = filters.Length - 1; i >= 0; i--) + { + var filter = filters[i]; + var next = pipeline; + pipeline = context => filter.ConsumeAsync(context, next); + } + + return pipeline; + } +} diff --git a/src/Vulthil.Messaging.RabbitMq/Consumers/HandlerKind.cs b/src/Vulthil.Messaging.RabbitMq/Consumers/HandlerKind.cs new file mode 100644 index 0000000..d74d147 --- /dev/null +++ b/src/Vulthil.Messaging.RabbitMq/Consumers/HandlerKind.cs @@ -0,0 +1,14 @@ +using Vulthil.Messaging.Abstractions.Consumers; + +namespace Vulthil.Messaging.RabbitMq.Consumers; + +/// +/// Classifies a dispatch handler by its consumer contract. +/// +internal enum HandlerKind +{ + /// A one-way . + Consumer, + /// A request/reply . + RequestConsumer, +} diff --git a/src/Vulthil.Messaging.RabbitMq/Consumers/IConsumerInvoker.cs b/src/Vulthil.Messaging.RabbitMq/Consumers/IConsumerInvoker.cs deleted file mode 100644 index af588c6..0000000 --- a/src/Vulthil.Messaging.RabbitMq/Consumers/IConsumerInvoker.cs +++ /dev/null @@ -1,171 +0,0 @@ -using System.Text.Json; -using Microsoft.Extensions.DependencyInjection; -using RabbitMQ.Client; -using RabbitMQ.Client.Events; -using Vulthil.Messaging.Abstractions.Consumers; -using Vulthil.Messaging.Abstractions.Publishers; -using Vulthil.Messaging.Queues; -using Vulthil.Messaging.RabbitMq.Requests; - -namespace Vulthil.Messaging.RabbitMq.Consumers; - - -internal interface IConsumerInvoker -{ - string RoutingKey { get; } - RetryPolicyDefinition? RetryPolicy { get; } - Task InvokeAsync(IServiceProvider sp, object message, BasicDeliverEventArgs ea, CancellationToken ct); -} - -internal static class ConsumePipelineFactory -{ - /// - /// Composes the registered instances around a terminal - /// delegate. The first filter resolved from DI becomes the outermost; the terminal delegate - /// runs innermost. - /// - public static ConsumeDelegate Build( - IServiceProvider sp, - ConsumeDelegate terminal) - where TMessage : notnull - { - var filters = sp.GetServices>().ToArray(); - if (filters.Length == 0) - { - return terminal; - } - - var pipeline = terminal; - // Iterate in reverse so the first-registered filter ends up outermost. - for (var i = filters.Length - 1; i >= 0; i--) - { - var filter = filters[i]; - var next = pipeline; - pipeline = context => filter.ConsumeAsync(context, next); - } - - return pipeline; - } -} - -internal sealed class ConsumerInvoker(string routingKey, RetryPolicyDefinition? retryPolicy) : IConsumerInvoker - where TConsumer : class, IConsumer - where TMessage : notnull -{ - /// - /// Represents this member. - /// - public string RoutingKey => routingKey; - - /// - /// Represents this member. - /// - public RetryPolicyDefinition? RetryPolicy => retryPolicy; - - /// - /// Executes this member. - /// - public async Task InvokeAsync( - IServiceProvider sp, - object message, - BasicDeliverEventArgs ea, - CancellationToken ct) - { - var consumer = sp.GetRequiredService(); - var publisher = sp.GetRequiredService(); - var sendEndpointProvider = sp.GetRequiredService(); - var context = MessageContext.CreateContext((TMessage)message, ea, publisher, sendEndpointProvider, ct); - - var pipeline = ConsumePipelineFactory.Build( - sp, - terminal: c => consumer.ConsumeAsync(c, c.CancellationToken)); - - await pipeline(context); - } -} - -internal interface IRpcInvoker -{ - string RoutingKey { get; } - RetryPolicyDefinition? RetryPolicy { get; } - Task InvokeAsync(IServiceProvider sp, object message, BasicDeliverEventArgs ea, IChannel channel, CancellationToken ct); -} - -internal sealed class RpcInvoker(string routingKey, RetryPolicyDefinition? retryPolicy) : IRpcInvoker - where TConsumer : class, IRequestConsumer - where TRequest : notnull - where TResponse : notnull -{ - /// - /// Represents this member. - /// - public string RoutingKey => routingKey; - /// - /// Represents this member. - /// - public RetryPolicyDefinition? RetryPolicy => retryPolicy; - - /// - /// Executes this member. - /// - public async Task InvokeAsync(IServiceProvider sp, object message, BasicDeliverEventArgs ea, IChannel channel, CancellationToken ct) - { - var consumer = sp.GetRequiredService(); - var publisher = sp.GetRequiredService(); - var sendEndpointProvider = sp.GetRequiredService(); - var jsonOptions = sp.GetRequiredService().JsonSerializerOptions; - var context = MessageContext.CreateContext((TRequest)message, ea, publisher, sendEndpointProvider, ct); - - MessageResult messageResult; - try - { - // The terminal stage captures the consumer's response so any wrapping filters can - // observe completion (e.g. for telemetry) before we serialize and publish it. - TResponse response = default!; - var responseProduced = false; - - var pipeline = ConsumePipelineFactory.Build( - sp, - terminal: async c => - { - response = await consumer.ConsumeAsync(c, c.CancellationToken); - responseProduced = true; - }); - - await pipeline(context); - - if (!responseProduced) - { - messageResult = MessageResult.Failure("Consume pipeline did not produce a response (a filter likely short-circuited the chain)."); - } - else - { - var responseByteArray = JsonSerializer.SerializeToUtf8Bytes(response, jsonOptions); - messageResult = MessageResult.Success(responseByteArray); - } - } - catch (Exception exception) - { - messageResult = MessageResult.Failure(exception.Message); - } - - await SendResponseAsync(ea, messageResult, channel, jsonOptions); - } - - private static async Task SendResponseAsync(BasicDeliverEventArgs ea, MessageResult response, IChannel channel, JsonSerializerOptions jsonOptions) - { - // SEND RESPONSE - if (!string.IsNullOrEmpty(ea.BasicProperties.ReplyTo)) - { - var responseBytes = JsonSerializer.SerializeToUtf8Bytes(response, jsonOptions); - var replyProps = new BasicProperties - { - CorrelationId = ea.BasicProperties.CorrelationId, - Type = response.GetType().FullName - }; - - await channel.BasicPublishAsync(string.Empty, ea.BasicProperties.ReplyTo, true, replyProps, responseBytes); - } - } - -} diff --git a/src/Vulthil.Messaging.RabbitMq/Consumers/MessageExecutionPlan.cs b/src/Vulthil.Messaging.RabbitMq/Consumers/MessageExecutionPlan.cs new file mode 100644 index 0000000..7b61246 --- /dev/null +++ b/src/Vulthil.Messaging.RabbitMq/Consumers/MessageExecutionPlan.cs @@ -0,0 +1,12 @@ +using Vulthil.Messaging.Queues; + +namespace Vulthil.Messaging.RabbitMq.Consumers; + +internal sealed record MessageExecutionPlan(MessageType MessageType) +{ + /// + /// The set of handlers that should run when a message of is delivered. + /// The broker is authoritative for delivery (queue-binding filter); every handler in this list runs on every delivery. + /// + public List Handlers { get; } = []; +} diff --git a/src/Vulthil.Messaging.RabbitMq/Consumers/MessageHandler.cs b/src/Vulthil.Messaging.RabbitMq/Consumers/MessageHandler.cs new file mode 100644 index 0000000..8c412bb --- /dev/null +++ b/src/Vulthil.Messaging.RabbitMq/Consumers/MessageHandler.cs @@ -0,0 +1,29 @@ +using RabbitMQ.Client; +using RabbitMQ.Client.Events; +using Vulthil.Messaging.Queues; + +namespace Vulthil.Messaging.RabbitMq.Consumers; + +/// +/// Transport-internal record describing how to invoke a single consumer or request consumer +/// for a delivered message. Built once at registration time by ; +/// the dispatch closure captures the typed consumer/message parameters so the worker can invoke +/// it without further reflection. +/// +internal sealed record MessageHandler +{ + /// The routing key (or topic pattern) the handler was registered with. Used for diagnostics; the broker is authoritative for delivery. + public required string RoutingKey { get; init; } + + /// The retry policy applied by the worker when this handler throws. + public RetryPolicyDefinition? RetryPolicy { get; init; } + + /// The consumer contract the handler implements. + public required HandlerKind Kind { get; init; } + + /// + /// Dispatches a deserialized message through the consume pipeline and (for RPC) publishes the response on the supplied channel. + /// Consumer-kind handlers ignore the channel parameter. + /// + public required Func DispatchAsync { get; init; } +} diff --git a/src/Vulthil.Messaging.RabbitMq/Consumers/MessageHandlerFactory.cs b/src/Vulthil.Messaging.RabbitMq/Consumers/MessageHandlerFactory.cs new file mode 100644 index 0000000..0b35ca5 --- /dev/null +++ b/src/Vulthil.Messaging.RabbitMq/Consumers/MessageHandlerFactory.cs @@ -0,0 +1,118 @@ +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using RabbitMQ.Client; +using RabbitMQ.Client.Events; +using Vulthil.Messaging.Abstractions.Consumers; +using Vulthil.Messaging.Abstractions.Publishers; +using Vulthil.Messaging.Queues; +using Vulthil.Messaging.RabbitMq.Requests; + +namespace Vulthil.Messaging.RabbitMq.Consumers; + +/// +/// Builds instances from open-generic consumer/message type pairs. +/// The factory methods are the single source of truth for the dispatch closure shape; reflection-driven +/// callers in bind to these via typed delegates so signature drift fails at startup. +/// +internal static class MessageHandlerFactory +{ + /// + /// Builds a handler for a one-way . + /// + public static MessageHandler ForConsumer(string routingKey, RetryPolicyDefinition? retryPolicy) + where TConsumer : class, IConsumer + where TMessage : notnull + => new() + { + RoutingKey = routingKey, + RetryPolicy = retryPolicy, + Kind = HandlerKind.Consumer, + DispatchAsync = async (sp, message, ea, _, ct) => + { + var consumer = sp.GetRequiredService(); + var publisher = sp.GetRequiredService(); + var sendEndpointProvider = sp.GetRequiredService(); + var context = MessageContext.CreateContext((TMessage)message, ea, publisher, sendEndpointProvider, ct); + + var pipeline = ConsumePipelineFactory.Build( + sp, + terminal: c => consumer.ConsumeAsync(c, c.CancellationToken)); + + await pipeline(context); + } + }; + + /// + /// Builds a handler for a request/reply . + /// + public static MessageHandler ForRequestConsumer(string routingKey, RetryPolicyDefinition? retryPolicy) + where TConsumer : class, IRequestConsumer + where TRequest : notnull + where TResponse : notnull + => new() + { + RoutingKey = routingKey, + RetryPolicy = retryPolicy, + Kind = HandlerKind.RequestConsumer, + DispatchAsync = async (sp, message, ea, channel, ct) => + { + var consumer = sp.GetRequiredService(); + var publisher = sp.GetRequiredService(); + var sendEndpointProvider = sp.GetRequiredService(); + var jsonOptions = sp.GetRequiredService().JsonSerializerOptions; + var context = MessageContext.CreateContext((TRequest)message, ea, publisher, sendEndpointProvider, ct); + + MessageResult messageResult; + try + { + // The terminal stage captures the consumer's response so any wrapping filters can + // observe completion (e.g. for telemetry) before we serialize and publish it. + TResponse response = default!; + var responseProduced = false; + + var pipeline = ConsumePipelineFactory.Build( + sp, + terminal: async c => + { + response = await consumer.ConsumeAsync(c, c.CancellationToken); + responseProduced = true; + }); + + await pipeline(context); + + if (!responseProduced) + { + messageResult = MessageResult.Failure("Consume pipeline did not produce a response (a filter likely short-circuited the chain)."); + } + else + { + var responseBytes = JsonSerializer.SerializeToUtf8Bytes(response, jsonOptions); + messageResult = MessageResult.Success(responseBytes); + } + } + catch (Exception exception) + { + messageResult = MessageResult.Failure(exception.Message); + } + + await SendResponseAsync(ea, messageResult, channel, jsonOptions); + } + }; + + private static async Task SendResponseAsync(BasicDeliverEventArgs ea, MessageResult response, IChannel channel, JsonSerializerOptions jsonOptions) + { + if (string.IsNullOrEmpty(ea.BasicProperties.ReplyTo)) + { + return; + } + + var responseBytes = JsonSerializer.SerializeToUtf8Bytes(response, jsonOptions); + var replyProps = new BasicProperties + { + CorrelationId = ea.BasicProperties.CorrelationId, + Type = response.GetType().FullName + }; + + await channel.BasicPublishAsync(string.Empty, ea.BasicProperties.ReplyTo, true, replyProps, responseBytes); + } +} diff --git a/src/Vulthil.Messaging.RabbitMq/Consumers/MessageTypeCache.cs b/src/Vulthil.Messaging.RabbitMq/Consumers/MessageTypeCache.cs index 92ee12d..db8b5e4 100644 --- a/src/Vulthil.Messaging.RabbitMq/Consumers/MessageTypeCache.cs +++ b/src/Vulthil.Messaging.RabbitMq/Consumers/MessageTypeCache.cs @@ -1,23 +1,26 @@ +using System.Collections.Concurrent; +using System.Reflection; using Vulthil.Messaging.Queues; namespace Vulthil.Messaging.RabbitMq.Consumers; -internal sealed record MessageExecutionPlan(MessageType MessageType) -{ - /// - /// Gets or sets this member value. - /// - public List StandardHandlers { get; } = []; - /// - /// Gets or sets this member value. - /// - public IRpcInvoker? RpcHandler { get; set; } -} - internal sealed class MessageTypeCache { private readonly Dictionary _plans = []; + // Process-wide caches keyed by closed-generic type parameters. Values are pure delegates with no + // per-cache state, so concurrent registration across tests/queues is safe. + private static readonly ConcurrentDictionary<(Type Consumer, Type Message), Func> _consumerFactoryCache = new(); + private static readonly ConcurrentDictionary<(Type Consumer, Type Request, Type Response), Func> _requestConsumerFactoryCache = new(); + + private static readonly MethodInfo _forConsumerMethod = typeof(MessageHandlerFactory) + .GetMethod(nameof(MessageHandlerFactory.ForConsumer), BindingFlags.Public | BindingFlags.Static) + ?? throw new InvalidOperationException($"{nameof(MessageHandlerFactory)}.{nameof(MessageHandlerFactory.ForConsumer)} not found."); + + private static readonly MethodInfo _forRequestConsumerMethod = typeof(MessageHandlerFactory) + .GetMethod(nameof(MessageHandlerFactory.ForRequestConsumer), BindingFlags.Public | BindingFlags.Static) + ?? throw new InvalidOperationException($"{nameof(MessageHandlerFactory)}.{nameof(MessageHandlerFactory.ForRequestConsumer)} not found."); + public void RegisterQueue(QueueDefinition queue) { foreach (var consumer in queue.Registrations.OfType()) @@ -25,14 +28,9 @@ public void RegisterQueue(QueueDefinition queue) var msgType = consumer.MessageType; var plan = GetOrAddPlan(msgType.Name, msgType); - var invokerType = typeof(ConsumerInvoker<,>) - .MakeGenericType(consumer.ConsumerType.Type, msgType.Type); - - var invoker = (IConsumerInvoker)Activator.CreateInstance( - invokerType, - args: [RabbitMqConstants.GetRoutingKey(consumer), consumer.RetryPolicy] - )!; - plan.StandardHandlers.Add(invoker); + var factory = GetConsumerFactory(consumer.ConsumerType.Type, msgType.Type); + var handler = factory(RabbitMqConstants.GetRoutingKey(consumer), consumer.RetryPolicy); + plan.Handlers.Add(handler); } foreach (var rpc in queue.Registrations.OfType()) @@ -40,17 +38,31 @@ public void RegisterQueue(QueueDefinition queue) var msgType = rpc.MessageType; var plan = GetOrAddPlan(msgType.Name, msgType); - var invokerType = typeof(RpcInvoker<,,>) - .MakeGenericType(rpc.ConsumerType.Type, msgType.Type, rpc.ResponseType); + if (plan.Handlers.Any(h => h.Kind == HandlerKind.RequestConsumer)) + { + throw new InvalidOperationException( + $"Queue '{queue.Name}' already has a request consumer registered for message type '{msgType.Name}'. " + + "A message type can have at most one request consumer per queue, since multiple responses would be ambiguous."); + } - var invoker = (IRpcInvoker)Activator.CreateInstance( - invokerType, - args: [RabbitMqConstants.GetRoutingKey(rpc), rpc.RetryPolicy] - )!; - plan.RpcHandler = invoker; + var factory = GetRequestConsumerFactory(rpc.ConsumerType.Type, msgType.Type, rpc.ResponseType); + var handler = factory(RabbitMqConstants.GetRoutingKey(rpc), rpc.RetryPolicy); + plan.Handlers.Add(handler); } } + private static Func GetConsumerFactory(Type consumerType, Type messageType) + => _consumerFactoryCache.GetOrAdd((consumerType, messageType), static key => + _forConsumerMethod + .MakeGenericMethod(key.Consumer, key.Message) + .CreateDelegate>()); + + private static Func GetRequestConsumerFactory(Type consumerType, Type requestType, Type responseType) + => _requestConsumerFactoryCache.GetOrAdd((consumerType, requestType, responseType), static key => + _forRequestConsumerMethod + .MakeGenericMethod(key.Consumer, key.Request, key.Response) + .CreateDelegate>()); + private MessageExecutionPlan GetOrAddPlan(string name, MessageType type) { if (!_plans.TryGetValue(name, out var plan)) diff --git a/src/Vulthil.Messaging.RabbitMq/Consumers/RabbitMqConsumerWorker.cs b/src/Vulthil.Messaging.RabbitMq/Consumers/RabbitMqConsumerWorker.cs index 79c7ecc..f117b6e 100644 --- a/src/Vulthil.Messaging.RabbitMq/Consumers/RabbitMqConsumerWorker.cs +++ b/src/Vulthil.Messaging.RabbitMq/Consumers/RabbitMqConsumerWorker.cs @@ -208,20 +208,11 @@ private async Task HandleMessageAsync(BasicDeliverEventArgs ea) return; } - var routingKey = ea.RoutingKey; await using var scope = _serviceScopeFactory.CreateAsyncScope(); - foreach (var handlerEntry in plan.StandardHandlers) + foreach (var handler in plan.Handlers) { - if (handlerEntry.RoutingKey == "#" || handlerEntry.RoutingKey == routingKey) - { - await handlerEntry.InvokeAsync(scope.ServiceProvider, message, ea, ea.CancellationToken); - } - } - - if (plan.RpcHandler is not null && (plan.RpcHandler.RoutingKey == "#" || plan.RpcHandler.RoutingKey == routingKey)) - { - await plan.RpcHandler.InvokeAsync(scope.ServiceProvider, message, ea, _channel, ea.CancellationToken); + await handler.DispatchAsync(scope.ServiceProvider, message, ea, _channel, ea.CancellationToken); } } diff --git a/tests/Vulthil.Messaging.RabbitMq.Tests/ConsumeFilterPipelineTests.cs b/tests/Vulthil.Messaging.RabbitMq.Tests/ConsumeFilterPipelineTests.cs index de50794..2f57698 100644 --- a/tests/Vulthil.Messaging.RabbitMq.Tests/ConsumeFilterPipelineTests.cs +++ b/tests/Vulthil.Messaging.RabbitMq.Tests/ConsumeFilterPipelineTests.cs @@ -106,10 +106,10 @@ public async Task PipelineWithNoFiltersInvokesConsumerDirectly() }); Target.RegisterQueue(queue); - var handler = Target.GetPlan(new MessageType(typeof(TestMessage)).Name)!.StandardHandlers[0]; + var handler = Target.GetPlan(new MessageType(typeof(TestMessage)).Name)!.Handlers[0]; - // Act - await handler.InvokeAsync(serviceProvider, new TestMessage("payload"), CreateDeliverEventArgs(), CancellationToken.None); + // Act — consumer-kind handlers ignore the channel argument. + await handler.DispatchAsync(serviceProvider, new TestMessage("payload"), CreateDeliverEventArgs(), Mock.Of(), CancellationToken.None); // Assert consumerInstance.Received.ShouldHaveSingleItem().Content.ShouldBe("payload"); @@ -139,10 +139,10 @@ public async Task PipelineComposesFiltersInRegistrationOrderOutermostFirst() }); Target.RegisterQueue(queue); - var handler = Target.GetPlan(new MessageType(typeof(TestMessage)).Name)!.StandardHandlers[0]; + var handler = Target.GetPlan(new MessageType(typeof(TestMessage)).Name)!.Handlers[0]; - // Act - await handler.InvokeAsync(serviceProvider, new TestMessage("payload"), CreateDeliverEventArgs(), CancellationToken.None); + // Act — consumer-kind handlers ignore the channel argument. + await handler.DispatchAsync(serviceProvider, new TestMessage("payload"), CreateDeliverEventArgs(), Mock.Of(), CancellationToken.None); // Assert trace.ShouldBe(["outer:before", "inner:before", "inner:after", "outer:after"]); @@ -172,10 +172,10 @@ public async Task FilterShortCircuitPreventsConsumerInvocation() }); Target.RegisterQueue(queue); - var handler = Target.GetPlan(new MessageType(typeof(TestMessage)).Name)!.StandardHandlers[0]; + var handler = Target.GetPlan(new MessageType(typeof(TestMessage)).Name)!.Handlers[0]; - // Act - await handler.InvokeAsync(serviceProvider, new TestMessage("payload"), CreateDeliverEventArgs(), CancellationToken.None); + // Act — consumer-kind handlers ignore the channel argument. + await handler.DispatchAsync(serviceProvider, new TestMessage("payload"), CreateDeliverEventArgs(), Mock.Of(), CancellationToken.None); // Assert trace.ShouldBe(["gate:before", "gate:short-circuit"]); @@ -206,7 +206,7 @@ public async Task RpcPipelineComposesFiltersAroundConsumerCall() }); Target.RegisterQueue(queue); - var handler = Target.GetPlan(new MessageType(typeof(TestRequest)).Name)!.RpcHandler!; + var handler = Target.GetPlan(new MessageType(typeof(TestRequest)).Name)!.Handlers.Single(h => h.Kind == HandlerKind.RequestConsumer); var channel = GetMock(); ReadOnlyMemory publishedBody = default; @@ -224,7 +224,7 @@ public async Task RpcPipelineComposesFiltersAroundConsumerCall() .Returns(ValueTask.CompletedTask); // Act - await handler.InvokeAsync( + await handler.DispatchAsync( serviceProvider, new TestRequest("query"), CreateDeliverEventArgs(replyTo: "reply", correlationId: "corr-1"), @@ -266,7 +266,7 @@ public async Task RpcPipelineShortCircuitProducesFailureResponse() }); Target.RegisterQueue(queue); - var handler = Target.GetPlan(new MessageType(typeof(TestRequest)).Name)!.RpcHandler!; + var handler = Target.GetPlan(new MessageType(typeof(TestRequest)).Name)!.Handlers.Single(h => h.Kind == HandlerKind.RequestConsumer); var channel = GetMock(); ReadOnlyMemory publishedBody = default; @@ -284,7 +284,7 @@ public async Task RpcPipelineShortCircuitProducesFailureResponse() .Returns(ValueTask.CompletedTask); // Act - await handler.InvokeAsync( + await handler.DispatchAsync( serviceProvider, new TestRequest("query"), CreateDeliverEventArgs(replyTo: "reply"), diff --git a/tests/Vulthil.Messaging.RabbitMq.Tests/MessageTypeCacheTests.cs b/tests/Vulthil.Messaging.RabbitMq.Tests/MessageTypeCacheTests.cs index c39f32f..edb82f5 100644 --- a/tests/Vulthil.Messaging.RabbitMq.Tests/MessageTypeCacheTests.cs +++ b/tests/Vulthil.Messaging.RabbitMq.Tests/MessageTypeCacheTests.cs @@ -105,8 +105,9 @@ public void RegisterQueueShouldRegisterStandardConsumers() // Assert var plan = Target.GetPlan(messageType.Name); plan.ShouldNotBeNull(); - plan.StandardHandlers.ShouldHaveSingleItem(); - plan.StandardHandlers[0].RoutingKey.ShouldBe("test.message"); + plan.Handlers.ShouldHaveSingleItem(); + plan.Handlers[0].Kind.ShouldBe(HandlerKind.Consumer); + plan.Handlers[0].RoutingKey.ShouldBe("test.message"); } [Fact] @@ -131,8 +132,8 @@ public void RegisterQueueShouldRegisterRequestConsumers() // Assert var plan = Target.GetPlan(messageType.Name); plan.ShouldNotBeNull(); - plan.RpcHandler.ShouldNotBeNull(); - plan.RpcHandler.RoutingKey.ShouldBe("test.request"); + var rpcHandler = plan.Handlers.Single(h => h.Kind == HandlerKind.RequestConsumer); + rpcHandler.RoutingKey.ShouldBe("test.request"); } [Fact] @@ -155,11 +156,11 @@ public async Task CompiledHandlerShouldCallConsumerWithCorrectMessage() Target.RegisterQueue(queue); var plan = Target.GetPlan(messageType.Name); - var handler = plan!.StandardHandlers[0]; + var handler = plan!.Handlers[0]; var testMessage = new TestMessage("Hello, World!"); - // Act - await handler.InvokeAsync(_serviceProvider, testMessage, CreateDeliverEventArgs(), CancellationToken.None); + // Act — consumer-kind handlers ignore the channel argument. + await handler.DispatchAsync(_serviceProvider, testMessage, CreateDeliverEventArgs(), Mock.Of(), CancellationToken.None); // Assert consumerInstance.ReceivedMessages.ShouldHaveSingleItem(); @@ -187,7 +188,7 @@ public async Task CompiledRpcHandlerShouldCallConsumerAndPublishResponse() Target.RegisterQueue(queue); var plan = Target.GetPlan(messageType.Name); - var handler = plan!.RpcHandler!; + var handler = plan!.Handlers.Single(h => h.Kind == HandlerKind.RequestConsumer); var testRequest = new TestRequest("Find users"); var deliveryArgs = CreateDeliverEventArgs(replyTo: "reply.queue", correlationId: "corr-1"); @@ -210,7 +211,7 @@ public async Task CompiledRpcHandlerShouldCallConsumerAndPublishResponse() .Returns(ValueTask.CompletedTask); // Act - await handler.InvokeAsync(_serviceProvider, testRequest, deliveryArgs, channel.Object, CancellationToken.None); + await handler.DispatchAsync(_serviceProvider, testRequest, deliveryArgs, channel.Object, CancellationToken.None); // Assert consumerInstance.ReceivedRequests.ShouldHaveSingleItem(); @@ -247,7 +248,7 @@ public async Task CompiledRpcHandlerShouldPublishFailureWhenConsumerThrows() Target.RegisterQueue(queue); var plan = Target.GetPlan(new MessageType(typeof(TestRequest)).Name); - var handler = plan!.RpcHandler!; + var handler = plan!.Handlers.Single(h => h.Kind == HandlerKind.RequestConsumer); var channel = GetMock(); ReadOnlyMemory publishedBody = default; @@ -266,7 +267,7 @@ public async Task CompiledRpcHandlerShouldPublishFailureWhenConsumerThrows() .Returns(ValueTask.CompletedTask); // Act - await handler.InvokeAsync( + await handler.DispatchAsync( _serviceProvider, new TestRequest("throw"), CreateDeliverEventArgs(replyTo: "reply.queue"), @@ -291,9 +292,11 @@ public void GetPlanShouldReturnNullForUnregisteredMessageType() } [Fact] - public void RegisterQueueShouldSupportMultipleHandlersForSameMessageType() + public void RegisterQueueShouldRecordEveryConsumerRegistration() { - // Arrange + // Arrange — two ConsumerRegistrations for the same message type on the same queue. The broker is + // authoritative for delivery; the plan records every handler and the worker dispatches each one + // on every delivery. Distinct routing-key semantics belong on distinct queues. var consumer = new ConsumerType(typeof(TestMessageConsumer)); var messageType = new MessageType(typeof(TestMessage)); @@ -320,22 +323,50 @@ public void RegisterQueueShouldSupportMultipleHandlersForSameMessageType() // Assert var plan = Target.GetPlan(messageType.Name); plan.ShouldNotBeNull(); - plan.StandardHandlers.Count.ShouldBe(2); - plan.StandardHandlers[0].RoutingKey.ShouldBe("route.1"); - plan.StandardHandlers[1].RoutingKey.ShouldBe("route.2"); + plan.Handlers.Count.ShouldBe(2); + plan.Handlers[0].RoutingKey.ShouldBe("route.1"); + plan.Handlers[1].RoutingKey.ShouldBe("route.2"); + plan.Handlers.ShouldAllBe(h => h.Kind == HandlerKind.Consumer); } [Fact] - public void RpcHandlerRoutingKeyShouldReturnHandlerRoutingKey() + public void RegisterQueueShouldRejectSecondRequestConsumerForSameMessageType() { - // Arrange - var messageType = new MessageType(typeof(TestRequest)); - var plan = new MessageExecutionPlan(messageType) + // Arrange — two request consumers for the same message type on the same queue would produce + // ambiguous responses, so registration must fail loudly. + var first = new RequestConsumerRegistration + { + ConsumerType = new ConsumerType(typeof(TestRequestConsumer)), + MessageType = new MessageType(typeof(TestRequest)), + ResponseType = typeof(TestResponse), + RoutingKey = "test.request" + }; + var second = new RequestConsumerRegistration { - RpcHandler = new RpcInvoker("custom.routing.key", null) + ConsumerType = new ConsumerType(typeof(ThrowingRequestConsumer)), + MessageType = new MessageType(typeof(TestRequest)), + ResponseType = typeof(TestResponse), + RoutingKey = "test.request.alt" }; + var queue = new QueueDefinition("TestQueue"); + queue.AddConsumer(first); + queue.AddConsumer(second); + + // Act & Assert + var ex = Should.Throw(() => Target.RegisterQueue(queue)); + ex.Message.ShouldContain("request consumer"); + ex.Message.ShouldContain("TestQueue"); + } + + [Fact] + public void HandlerRoutingKeyFromFactoryShouldRoundTrip() + { + // Arrange — exercises the typed-generic factory path directly. + var handler = MessageHandlerFactory.ForRequestConsumer("custom.routing.key", retryPolicy: null); + // Act & Assert - plan.RpcHandler.RoutingKey.ShouldBe("custom.routing.key"); + handler.RoutingKey.ShouldBe("custom.routing.key"); + handler.Kind.ShouldBe(HandlerKind.RequestConsumer); } } From 98dcc1dbeee1175bb582366fab8a61a762a22836 Mon Sep 17 00:00:00 2001 From: Vulthil Date: Thu, 28 May 2026 20:39:03 +0200 Subject: [PATCH 08/42] feat(messaging): wrap publishes in MessageEnvelope and key dispatch by URN --- .../Consumers/MessageContext.cs | 63 ++++++++++++++++ .../Consumers/MessageExecutionPlan.cs | 2 +- .../Consumers/MessageHandler.cs | 7 +- .../Consumers/MessageHandlerFactory.cs | 13 +++- .../Consumers/MessageTypeCache.cs | 59 +++++++++++---- .../Consumers/RabbitMqConsumerWorker.cs | 46 ++++++++++-- .../Envelope/MessageEnvelope.cs | 73 +++++++++++++++++++ .../Envelope/MessageEnvelopeFactory.cs | 58 +++++++++++++++ .../Publishing/RabbitMqPublisher.cs | 13 +++- src/Vulthil.Messaging.RabbitMq/RabbitMqBus.cs | 3 +- .../Requests/RabbitMqRequester.cs | 16 ++-- .../Sending/RabbitMqSendEndpoint.cs | 13 +++- .../IMessageConfigurationProvider.cs | 17 +++++ src/Vulthil.Messaging/MessageConfiguration.cs | 28 ++++++- .../MessagingConfigurator.cs | 2 + src/Vulthil.Messaging/MessagingOptions.cs | 59 ++++++++++++--- src/Vulthil.Messaging/PublicAPI.Unshipped.txt | 4 + .../ConsumeFilterPipelineTests.cs | 16 ++-- .../MessageTypeCacheTests.cs | 16 ++-- .../RabbitMqPublisherExtendedTests.cs | 21 +----- .../RabbitMqSendEndpointTests.cs | 26 +------ 21 files changed, 439 insertions(+), 116 deletions(-) create mode 100644 src/Vulthil.Messaging.RabbitMq/Envelope/MessageEnvelope.cs create mode 100644 src/Vulthil.Messaging.RabbitMq/Envelope/MessageEnvelopeFactory.cs diff --git a/src/Vulthil.Messaging.RabbitMq/Consumers/MessageContext.cs b/src/Vulthil.Messaging.RabbitMq/Consumers/MessageContext.cs index 0ba0c94..b79683c 100644 --- a/src/Vulthil.Messaging.RabbitMq/Consumers/MessageContext.cs +++ b/src/Vulthil.Messaging.RabbitMq/Consumers/MessageContext.cs @@ -1,6 +1,7 @@ using RabbitMQ.Client.Events; using Vulthil.Messaging.Abstractions.Consumers; using Vulthil.Messaging.Abstractions.Publishers; +using Vulthil.Messaging.RabbitMq.Envelope; using Vulthil.Messaging.RabbitMq.Sending; namespace Vulthil.Messaging.RabbitMq.Consumers; @@ -112,6 +113,7 @@ public static MessageContext CreateContext(TMessage message, /// /// Creates a live typed bound to the specified transport services and cancellation token. + /// Used by the bare-JSON receive path; metadata comes from and its BasicProperties headers. /// public static MessageContext CreateContext( TMessage message, @@ -121,6 +123,19 @@ public static MessageContext CreateContext( CancellationToken cancellationToken) => BuildTypedMetadata(message, ea, publisher, sendEndpointProvider, cancellationToken); + /// + /// Creates a live typed from the envelope-bearing receive path. + /// Metadata comes from the envelope; transport-level fields (RoutingKey, Redelivered, retry count) still come from . + /// + public static MessageContext CreateContext( + TMessage message, + BasicDeliverEventArgs ea, + MessageEnvelope envelope, + IPublisher publisher, + ISendEndpointProvider sendEndpointProvider, + CancellationToken cancellationToken) => + BuildTypedMetadataFromEnvelope(message, ea, envelope, publisher, sendEndpointProvider, cancellationToken); + private static MessageContext BuildMetadata( BasicDeliverEventArgs ea, IPublisher publisher, @@ -186,6 +201,54 @@ private static MessageContext BuildTypedMetadata( ExpirationTime = RabbitMqConstants.TryParseExpiration(props.Expiration) }; } + + private static MessageContext BuildTypedMetadataFromEnvelope( + TMessage message, + BasicDeliverEventArgs ea, + MessageEnvelope envelope, + IPublisher publisher, + ISendEndpointProvider sendEndpointProvider, + CancellationToken cancellationToken) + { + var props = ea.BasicProperties; + var transportHeaders = props.Headers ?? new Dictionary(); + var userHeaders = envelope.Headers is { } h ? new Dictionary(h) : []; + + return new MessageContext + { + Message = message, + Publisher = publisher, + SendEndpointProvider = sendEndpointProvider, + CancellationToken = cancellationToken, + MessageId = envelope.MessageId, + CorrelationId = envelope.CorrelationId ?? string.Empty, + RequestId = envelope.RequestId ?? envelope.CorrelationId, + RoutingKey = ea.RoutingKey, + Headers = userHeaders, + Redelivered = ea.Redelivered, + RetryCount = RabbitMqConstants.GetRetryCount(transportHeaders), + ConversationId = envelope.ConversationId, + InitiatorId = envelope.InitiatorId, + SourceAddress = ParseAddress(envelope.SourceAddress), + DestinationAddress = ParseAddress(envelope.DestinationAddress), + ResponseAddress = ParseAddress(envelope.ResponseAddress) + ?? (string.IsNullOrEmpty(props.ReplyTo) ? null : new Uri($"queue:{props.ReplyTo}")), + FaultAddress = ParseAddress(envelope.FaultAddress), + SentTime = envelope.SentTime, + ExpirationTime = envelope.ExpirationTime, + }; + } + + private static Uri? ParseAddress(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + return Uri.TryCreate(value, UriKind.Absolute, out var uri) + ? uri + : new Uri($"queue:{value}"); + } } internal sealed record MessageContext : MessageContext, IMessageContext diff --git a/src/Vulthil.Messaging.RabbitMq/Consumers/MessageExecutionPlan.cs b/src/Vulthil.Messaging.RabbitMq/Consumers/MessageExecutionPlan.cs index 7b61246..8808ef0 100644 --- a/src/Vulthil.Messaging.RabbitMq/Consumers/MessageExecutionPlan.cs +++ b/src/Vulthil.Messaging.RabbitMq/Consumers/MessageExecutionPlan.cs @@ -2,7 +2,7 @@ namespace Vulthil.Messaging.RabbitMq.Consumers; -internal sealed record MessageExecutionPlan(MessageType MessageType) +internal sealed record MessageExecutionPlan(MessageType MessageType, Uri Urn) { /// /// The set of handlers that should run when a message of is delivered. diff --git a/src/Vulthil.Messaging.RabbitMq/Consumers/MessageHandler.cs b/src/Vulthil.Messaging.RabbitMq/Consumers/MessageHandler.cs index 8c412bb..4c344a1 100644 --- a/src/Vulthil.Messaging.RabbitMq/Consumers/MessageHandler.cs +++ b/src/Vulthil.Messaging.RabbitMq/Consumers/MessageHandler.cs @@ -1,6 +1,7 @@ using RabbitMQ.Client; using RabbitMQ.Client.Events; using Vulthil.Messaging.Queues; +using Vulthil.Messaging.RabbitMq.Envelope; namespace Vulthil.Messaging.RabbitMq.Consumers; @@ -23,7 +24,9 @@ internal sealed record MessageHandler /// /// Dispatches a deserialized message through the consume pipeline and (for RPC) publishes the response on the supplied channel. - /// Consumer-kind handlers ignore the channel parameter. + /// Consumer-kind handlers ignore the channel parameter. The envelope is non-null on the standard receive path + /// (Vulthil-produced messages) and null on the bare-JSON compat path (external producers); the closure picks the + /// appropriate MessageContext.CreateContext overload. /// - public required Func DispatchAsync { get; init; } + public required Func DispatchAsync { get; init; } } diff --git a/src/Vulthil.Messaging.RabbitMq/Consumers/MessageHandlerFactory.cs b/src/Vulthil.Messaging.RabbitMq/Consumers/MessageHandlerFactory.cs index 0b35ca5..fd38495 100644 --- a/src/Vulthil.Messaging.RabbitMq/Consumers/MessageHandlerFactory.cs +++ b/src/Vulthil.Messaging.RabbitMq/Consumers/MessageHandlerFactory.cs @@ -5,6 +5,7 @@ using Vulthil.Messaging.Abstractions.Consumers; using Vulthil.Messaging.Abstractions.Publishers; using Vulthil.Messaging.Queues; +using Vulthil.Messaging.RabbitMq.Envelope; using Vulthil.Messaging.RabbitMq.Requests; namespace Vulthil.Messaging.RabbitMq.Consumers; @@ -27,12 +28,14 @@ public static MessageHandler ForConsumer(string routingKey, RoutingKey = routingKey, RetryPolicy = retryPolicy, Kind = HandlerKind.Consumer, - DispatchAsync = async (sp, message, ea, _, ct) => + DispatchAsync = async (sp, message, ea, envelope, _, ct) => { var consumer = sp.GetRequiredService(); var publisher = sp.GetRequiredService(); var sendEndpointProvider = sp.GetRequiredService(); - var context = MessageContext.CreateContext((TMessage)message, ea, publisher, sendEndpointProvider, ct); + var context = envelope is null + ? MessageContext.CreateContext((TMessage)message, ea, publisher, sendEndpointProvider, ct) + : MessageContext.CreateContext((TMessage)message, ea, envelope, publisher, sendEndpointProvider, ct); var pipeline = ConsumePipelineFactory.Build( sp, @@ -54,13 +57,15 @@ public static MessageHandler ForRequestConsumer( RoutingKey = routingKey, RetryPolicy = retryPolicy, Kind = HandlerKind.RequestConsumer, - DispatchAsync = async (sp, message, ea, channel, ct) => + DispatchAsync = async (sp, message, ea, envelope, channel, ct) => { var consumer = sp.GetRequiredService(); var publisher = sp.GetRequiredService(); var sendEndpointProvider = sp.GetRequiredService(); var jsonOptions = sp.GetRequiredService().JsonSerializerOptions; - var context = MessageContext.CreateContext((TRequest)message, ea, publisher, sendEndpointProvider, ct); + var context = envelope is null + ? MessageContext.CreateContext((TRequest)message, ea, publisher, sendEndpointProvider, ct) + : MessageContext.CreateContext((TRequest)message, ea, envelope, publisher, sendEndpointProvider, ct); MessageResult messageResult; try diff --git a/src/Vulthil.Messaging.RabbitMq/Consumers/MessageTypeCache.cs b/src/Vulthil.Messaging.RabbitMq/Consumers/MessageTypeCache.cs index db8b5e4..be6dedc 100644 --- a/src/Vulthil.Messaging.RabbitMq/Consumers/MessageTypeCache.cs +++ b/src/Vulthil.Messaging.RabbitMq/Consumers/MessageTypeCache.cs @@ -6,27 +6,30 @@ namespace Vulthil.Messaging.RabbitMq.Consumers; internal sealed class MessageTypeCache { - private readonly Dictionary _plans = []; - - // Process-wide caches keyed by closed-generic type parameters. Values are pure delegates with no - // per-cache state, so concurrent registration across tests/queues is safe. - private static readonly ConcurrentDictionary<(Type Consumer, Type Message), Func> _consumerFactoryCache = new(); - private static readonly ConcurrentDictionary<(Type Consumer, Type Request, Type Response), Func> _requestConsumerFactoryCache = new(); + private readonly IMessageConfigurationProvider _provider; + private readonly Dictionary _plansByUrn = []; + private readonly Dictionary _plansByFullName = new(StringComparer.Ordinal); + private readonly ConcurrentDictionary<(Type Consumer, Type Message), Func> _consumerFactoryCache = new(); + private readonly ConcurrentDictionary<(Type Consumer, Type Request, Type Response), Func> _requestConsumerFactoryCache = new(); private static readonly MethodInfo _forConsumerMethod = typeof(MessageHandlerFactory) .GetMethod(nameof(MessageHandlerFactory.ForConsumer), BindingFlags.Public | BindingFlags.Static) ?? throw new InvalidOperationException($"{nameof(MessageHandlerFactory)}.{nameof(MessageHandlerFactory.ForConsumer)} not found."); - private static readonly MethodInfo _forRequestConsumerMethod = typeof(MessageHandlerFactory) .GetMethod(nameof(MessageHandlerFactory.ForRequestConsumer), BindingFlags.Public | BindingFlags.Static) ?? throw new InvalidOperationException($"{nameof(MessageHandlerFactory)}.{nameof(MessageHandlerFactory.ForRequestConsumer)} not found."); + public MessageTypeCache(IMessageConfigurationProvider provider) + { + _provider = provider; + } + public void RegisterQueue(QueueDefinition queue) { foreach (var consumer in queue.Registrations.OfType()) { var msgType = consumer.MessageType; - var plan = GetOrAddPlan(msgType.Name, msgType); + var plan = GetOrAddPlan(msgType); var factory = GetConsumerFactory(consumer.ConsumerType.Type, msgType.Type); var handler = factory(RabbitMqConstants.GetRoutingKey(consumer), consumer.RetryPolicy); @@ -36,7 +39,7 @@ public void RegisterQueue(QueueDefinition queue) foreach (var rpc in queue.Registrations.OfType()) { var msgType = rpc.MessageType; - var plan = GetOrAddPlan(msgType.Name, msgType); + var plan = GetOrAddPlan(msgType); if (plan.Handlers.Any(h => h.Kind == HandlerKind.RequestConsumer)) { @@ -51,27 +54,51 @@ public void RegisterQueue(QueueDefinition queue) } } - private static Func GetConsumerFactory(Type consumerType, Type messageType) + private Func GetConsumerFactory(Type consumerType, Type messageType) => _consumerFactoryCache.GetOrAdd((consumerType, messageType), static key => _forConsumerMethod .MakeGenericMethod(key.Consumer, key.Message) .CreateDelegate>()); - private static Func GetRequestConsumerFactory(Type consumerType, Type requestType, Type responseType) + private Func GetRequestConsumerFactory(Type consumerType, Type requestType, Type responseType) => _requestConsumerFactoryCache.GetOrAdd((consumerType, requestType, responseType), static key => _forRequestConsumerMethod .MakeGenericMethod(key.Consumer, key.Request, key.Response) .CreateDelegate>()); - private MessageExecutionPlan GetOrAddPlan(string name, MessageType type) + private MessageExecutionPlan GetOrAddPlan(MessageType messageType) { - if (!_plans.TryGetValue(name, out var plan)) + var urn = _provider.GetUrn(messageType.Type); + if (_plansByUrn.TryGetValue(urn, out var existing)) { - plan = new MessageExecutionPlan(type); - _plans[name] = plan; + return existing; } + + var plan = new MessageExecutionPlan(messageType, urn); + _plansByUrn[urn] = plan; + _plansByFullName[messageType.Name] = plan; return plan; } - public MessageExecutionPlan? GetPlan(string key) => _plans.GetValueOrDefault(key); + /// + /// Resolves a plan from the wire URN (envelope path). Returns when no plan matches. + /// + public MessageExecutionPlan? GetPlanByUrn(Uri urn) => _plansByUrn.GetValueOrDefault(urn); + + /// + /// Resolves a plan from the CLR full type name (bare-JSON compat path). Returns when no plan matches. + /// + public MessageExecutionPlan? GetPlanByFullName(string fullName) => _plansByFullName.GetValueOrDefault(fullName); + + /// + /// Convenience lookup used by tests and the bare-JSON receive path: tries URN parsing first, falls back to CLR full name. + /// + public MessageExecutionPlan? GetPlan(string key) + { + if (Uri.TryCreate(key, UriKind.Absolute, out var urn) && _plansByUrn.TryGetValue(urn, out var plan)) + { + return plan; + } + return _plansByFullName.GetValueOrDefault(key); + } } diff --git a/src/Vulthil.Messaging.RabbitMq/Consumers/RabbitMqConsumerWorker.cs b/src/Vulthil.Messaging.RabbitMq/Consumers/RabbitMqConsumerWorker.cs index f117b6e..9759ed9 100644 --- a/src/Vulthil.Messaging.RabbitMq/Consumers/RabbitMqConsumerWorker.cs +++ b/src/Vulthil.Messaging.RabbitMq/Consumers/RabbitMqConsumerWorker.cs @@ -7,6 +7,7 @@ using RabbitMQ.Client.Events; using Vulthil.Messaging.Abstractions.Consumers; using Vulthil.Messaging.Queues; +using Vulthil.Messaging.RabbitMq.Envelope; using Vulthil.Messaging.RabbitMq.Logging; using Vulthil.Messaging.RabbitMq.Telemetry; @@ -180,30 +181,39 @@ private async Task PublishFaultIfRequestedAsync(Exception ex, BasicDeliverEventA private async Task HandleMessageAsync(BasicDeliverEventArgs ea) { - var messageTypeName = ea.BasicProperties.Type ?? ea.Exchange; - var plan = _typeCache.GetPlan(messageTypeName); + var bareTypeName = ea.BasicProperties.Type ?? ea.Exchange; + + var envelope = TryParseEnvelope(ea.Body, _jsonOptions); + + var plan = envelope is not null + ? _typeCache.GetPlanByUrn(envelope.MessageType) + : _typeCache.GetPlan(bareTypeName); + + var diagnosticTypeName = envelope?.MessageType.AbsoluteUri ?? bareTypeName; if (plan == null) { - MessagingLog.NoExecutionPlan(_logger, _queueDefinition.Name, messageTypeName, ea.RoutingKey); + MessagingLog.NoExecutionPlan(_logger, _queueDefinition.Name, diagnosticTypeName, ea.RoutingKey); return; } object? message; try { - message = JsonSerializer.Deserialize(ea.Body.Span, plan.MessageType.Type, _jsonOptions); + message = envelope is not null + ? envelope.Message.Deserialize(plan.MessageType.Type, _jsonOptions) + : JsonSerializer.Deserialize(ea.Body.Span, plan.MessageType.Type, _jsonOptions); } catch (JsonException jsonEx) { - MessagingLog.PoisonMessage(_logger, jsonEx, _queueDefinition.Name, messageTypeName, ea.RoutingKey); + MessagingLog.PoisonMessage(_logger, jsonEx, _queueDefinition.Name, diagnosticTypeName, ea.RoutingKey); await _channel.BasicNackAsync(ea.DeliveryTag, false, false); return; } if (message is null) { - MessagingLog.PoisonMessage(_logger, new JsonException("Deserializer returned null."), _queueDefinition.Name, messageTypeName, ea.RoutingKey); + MessagingLog.PoisonMessage(_logger, new JsonException("Deserializer returned null."), _queueDefinition.Name, diagnosticTypeName, ea.RoutingKey); await _channel.BasicNackAsync(ea.DeliveryTag, false, false); return; } @@ -212,7 +222,29 @@ private async Task HandleMessageAsync(BasicDeliverEventArgs ea) foreach (var handler in plan.Handlers) { - await handler.DispatchAsync(scope.ServiceProvider, message, ea, _channel, ea.CancellationToken); + await handler.DispatchAsync(scope.ServiceProvider, message, ea, envelope, _channel, ea.CancellationToken); + } + } + + /// + /// Attempts to deserialize the body as a . Returns + /// on any parse error or when the body is not envelope-shaped — the caller then takes the bare-JSON path. + /// + private static MessageEnvelope? TryParseEnvelope(ReadOnlyMemory body, JsonSerializerOptions options) + { + try + { + var envelope = JsonSerializer.Deserialize(body.Span, options); + + if (envelope is null || envelope.MessageType is null || envelope.Message.ValueKind == JsonValueKind.Undefined) + { + return null; + } + return envelope; + } + catch (JsonException) + { + return null; } } diff --git a/src/Vulthil.Messaging.RabbitMq/Envelope/MessageEnvelope.cs b/src/Vulthil.Messaging.RabbitMq/Envelope/MessageEnvelope.cs new file mode 100644 index 0000000..f805303 --- /dev/null +++ b/src/Vulthil.Messaging.RabbitMq/Envelope/MessageEnvelope.cs @@ -0,0 +1,73 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Vulthil.Messaging.RabbitMq.Envelope; + +/// +/// On-the-wire wrapper for messages carried by the RabbitMQ transport. The body of every produced +/// message is a serialized ; the receiver falls back to bare JSON only +/// for compatibility with non-Vulthil producers. +/// +/// +/// All metadata that used to live in AMQP BasicProperties + ad-hoc headers is promoted to +/// first-class envelope fields. AMQP properties are still mirrored for broker tooling +/// (CorrelationId, MessageId, Type) and trace propagation, but the envelope is the source of truth. +/// +internal sealed record MessageEnvelope +{ + /// The unique identifier for this message. + [JsonPropertyName("messageId")] + public string? MessageId { get; init; } + + /// For request/reply: the identifier of the request being answered, or that this message represents. + [JsonPropertyName("requestId")] + public string? RequestId { get; init; } + + /// Business correlation identifier shared across all messages in a logical operation. + [JsonPropertyName("correlationId")] + public string? CorrelationId { get; init; } + + /// Identifier shared by every message in the same conversation across services. + [JsonPropertyName("conversationId")] + public string? ConversationId { get; init; } + + /// The identifier of the message that initiated this chain. + [JsonPropertyName("initiatorId")] + public string? InitiatorId { get; init; } + + /// Address of the endpoint that produced this message. + [JsonPropertyName("sourceAddress")] + public string? SourceAddress { get; init; } + + /// Address of the endpoint this message was sent to (for point-to-point sends). + [JsonPropertyName("destinationAddress")] + public string? DestinationAddress { get; init; } + + /// Address where replies to this message should be sent. + [JsonPropertyName("responseAddress")] + public string? ResponseAddress { get; init; } + + /// Address where fault notifications should be sent. + [JsonPropertyName("faultAddress")] + public string? FaultAddress { get; init; } + + /// The stable wire URN identifying the message type, e.g. urn:message:Acme.Orders:OrderPlaced. + [JsonPropertyName("messageType")] + public required Uri MessageType { get; init; } + + /// The serialized payload. Receivers deserialize this into the CLR type resolved from . + [JsonPropertyName("message")] + public required JsonElement Message { get; init; } + + /// UTC timestamp when the message was sent. + [JsonPropertyName("sentTime")] + public DateTimeOffset? SentTime { get; init; } + + /// UTC timestamp after which the message should be discarded. + [JsonPropertyName("expirationTime")] + public DateTimeOffset? ExpirationTime { get; init; } + + /// Custom transport headers (e.g. tenancy markers, retry counters). + [JsonPropertyName("headers")] + public Dictionary? Headers { get; init; } +} diff --git a/src/Vulthil.Messaging.RabbitMq/Envelope/MessageEnvelopeFactory.cs b/src/Vulthil.Messaging.RabbitMq/Envelope/MessageEnvelopeFactory.cs new file mode 100644 index 0000000..f9c0e5f --- /dev/null +++ b/src/Vulthil.Messaging.RabbitMq/Envelope/MessageEnvelopeFactory.cs @@ -0,0 +1,58 @@ +using System.Text.Json; +using Vulthil.Messaging.RabbitMq.Requests; + +namespace Vulthil.Messaging.RabbitMq.Envelope; + +internal static class MessageEnvelopeFactory +{ + private static readonly HashSet PromotedHeaderKeys = new(StringComparer.Ordinal) + { + "ConversationId", + "InitiatorId", + "SourceAddress", + "DestinationAddress", + "ResponseAddress", + "FaultAddress", + }; + + /// + /// Builds a from the resolved publish state for a single outgoing message. + /// + public static MessageEnvelope Create( + TMessage message, + PublishContext publishContext, + string messageId, + string correlationId, + Uri urn, + JsonSerializerOptions jsonOptions) + where TMessage : notnull + { + // Copy user headers, removing the keys that we promote to typed envelope fields. + Dictionary? userHeaders = null; + foreach (var (key, value) in publishContext.Headers) + { + if (PromotedHeaderKeys.Contains(key)) + { + continue; + } + userHeaders ??= new Dictionary(StringComparer.Ordinal); + userHeaders[key] = value; + } + + return new MessageEnvelope + { + MessageId = messageId, + CorrelationId = correlationId, + ConversationId = publishContext.ConversationId, + InitiatorId = publishContext.InitiatorId, + SourceAddress = publishContext.SourceAddress?.ToString(), + DestinationAddress = publishContext.DestinationAddress?.ToString(), + ResponseAddress = publishContext.ResponseAddress?.ToString(), + FaultAddress = publishContext.FaultAddress?.ToString(), + MessageType = urn, + Message = JsonSerializer.SerializeToElement(message, jsonOptions), + SentTime = DateTimeOffset.UtcNow, + Headers = userHeaders, + }; + } +} diff --git a/src/Vulthil.Messaging.RabbitMq/Publishing/RabbitMqPublisher.cs b/src/Vulthil.Messaging.RabbitMq/Publishing/RabbitMqPublisher.cs index 5f04ea9..cb004eb 100644 --- a/src/Vulthil.Messaging.RabbitMq/Publishing/RabbitMqPublisher.cs +++ b/src/Vulthil.Messaging.RabbitMq/Publishing/RabbitMqPublisher.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.Logging; using RabbitMQ.Client; using Vulthil.Messaging.Abstractions.Publishers; +using Vulthil.Messaging.RabbitMq.Envelope; using Vulthil.Messaging.RabbitMq.Logging; using Vulthil.Messaging.RabbitMq.Requests; using Vulthil.Messaging.RabbitMq.Telemetry; @@ -113,6 +114,8 @@ public async Task PublishAsync( var messageId = publishContext.MessageId ?? Guid.CreateVersion7().ToString(); var exchange = messageConfiguration.Exchange; + var urn = messageConfiguration.Urn; + var urnString = urn.AbsoluteUri; using var activity = MessagingInstrumentation.ActivitySource.StartActivity( $"{exchange} publish", @@ -124,14 +127,14 @@ public async Task PublishAsync( activity.SetTag(MessagingInstrumentation.Tags.MessagingOperation, "publish"); activity.SetTag(MessagingInstrumentation.Tags.MessagingDestination, exchange); activity.SetTag(MessagingInstrumentation.Tags.MessagingRoutingKey, routingKey); - activity.SetTag(MessagingInstrumentation.Tags.MessageType, type.FullName); + activity.SetTag(MessagingInstrumentation.Tags.MessageType, urnString); activity.SetTag(MessagingInstrumentation.Tags.MessagingMessageId, messageId); activity.SetTag(MessagingInstrumentation.Tags.MessagingCorrelationId, correlationId); } var properties = new BasicProperties() { - Type = type.FullName, + Type = urnString, MessageId = messageId, ReplyTo = PublishContext.ResolveRoutingKeyFromUri(publishContext.ResponseAddress), CorrelationId = correlationId, @@ -141,9 +144,11 @@ public async Task PublishAsync( Timestamp = new AmqpTimestamp(DateTimeOffset.UtcNow.ToUnixTimeSeconds()), }; - var body = JsonSerializer.SerializeToUtf8Bytes(message, _messageConfigurationProvider.JsonSerializerOptions); + var envelope = MessageEnvelopeFactory.Create( + message, publishContext, messageId, correlationId, urn, _messageConfigurationProvider.JsonSerializerOptions); + var body = JsonSerializer.SerializeToUtf8Bytes(envelope, _messageConfigurationProvider.JsonSerializerOptions); - MessagingLog.Publishing(_logger, type.FullName ?? type.Name, exchange, routingKey, messageId); + MessagingLog.Publishing(_logger, urnString, exchange, routingKey, messageId); try { diff --git a/src/Vulthil.Messaging.RabbitMq/RabbitMqBus.cs b/src/Vulthil.Messaging.RabbitMq/RabbitMqBus.cs index 96f63fd..5b77ee8 100644 --- a/src/Vulthil.Messaging.RabbitMq/RabbitMqBus.cs +++ b/src/Vulthil.Messaging.RabbitMq/RabbitMqBus.cs @@ -16,7 +16,7 @@ internal sealed class RabbitMqBus : ITransport, IAsyncDisposable private readonly RabbitMqBusStartupStatus _startupStatus; private readonly ILogger _logger; private readonly ILoggerFactory _loggerFactory; - private readonly MessageTypeCache _typeCache = new(); + private readonly MessageTypeCache _typeCache; private readonly List _workers = []; public RabbitMqBus( @@ -33,6 +33,7 @@ public RabbitMqBus( _startupStatus = startupStatus; _logger = logger; _loggerFactory = loggerFactory; + _typeCache = new MessageTypeCache(messageConfigurationProvider); } public async Task StartAsync(CancellationToken cancellationToken = default) diff --git a/src/Vulthil.Messaging.RabbitMq/Requests/RabbitMqRequester.cs b/src/Vulthil.Messaging.RabbitMq/Requests/RabbitMqRequester.cs index d2aa29d..ac6287f 100644 --- a/src/Vulthil.Messaging.RabbitMq/Requests/RabbitMqRequester.cs +++ b/src/Vulthil.Messaging.RabbitMq/Requests/RabbitMqRequester.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.Logging; using RabbitMQ.Client; using Vulthil.Messaging.Abstractions.Publishers; +using Vulthil.Messaging.RabbitMq.Envelope; using Vulthil.Messaging.RabbitMq.Logging; using Vulthil.Messaging.RabbitMq.Publishing; using Vulthil.Messaging.RabbitMq.Telemetry; @@ -69,6 +70,8 @@ public async Task> RequestAsync( var messageId = publishContext.MessageId ?? Guid.CreateVersion7().ToString(); var exchange = messageConfiguration.Exchange; + var urn = messageConfiguration.Urn; + var urnString = urn.AbsoluteUri; var replyQueue = await _listener.GetReplyToQueueNameAsync(cancellationToken); var replyTo = PublishContext.ResolveRoutingKeyFromUri(publishContext.ResponseAddress) ?? replyQueue; @@ -83,13 +86,13 @@ public async Task> RequestAsync( activity.SetTag(MessagingInstrumentation.Tags.MessagingOperation, "request"); activity.SetTag(MessagingInstrumentation.Tags.MessagingDestination, exchange); activity.SetTag(MessagingInstrumentation.Tags.MessagingRoutingKey, routingKey); - activity.SetTag(MessagingInstrumentation.Tags.MessageType, type.FullName); + activity.SetTag(MessagingInstrumentation.Tags.MessageType, urnString); activity.SetTag(MessagingInstrumentation.Tags.MessagingMessageId, messageId); activity.SetTag(MessagingInstrumentation.Tags.MessagingCorrelationId, correlationId); } _listener.RegisterWaiter(correlationId, tcs); - MessagingLog.RequestSending(_logger, type.FullName ?? type.Name, correlationId, DefaultTimeout.TotalSeconds); + MessagingLog.RequestSending(_logger, urnString, correlationId, DefaultTimeout.TotalSeconds); try { @@ -98,14 +101,15 @@ public async Task> RequestAsync( CorrelationId = correlationId, ReplyTo = replyTo, ContentType = RabbitMqConstants.ContentType, - Type = type.FullName, + Type = urnString, Timestamp = new AmqpTimestamp(DateTimeOffset.UtcNow.ToUnixTimeSeconds()), Expiration = DefaultTimeout.TotalMilliseconds.ToString("F0", CultureInfo.InvariantCulture), Headers = publishContext.Headers, MessageId = messageId, }; - var body = JsonSerializer.SerializeToUtf8Bytes(message, JsonOptions); + var envelope = MessageEnvelopeFactory.Create(message, publishContext, messageId, correlationId, urn, JsonOptions); + var body = JsonSerializer.SerializeToUtf8Bytes(envelope, JsonOptions); await _publisher.InternalPublishAsync(body, props, routingKey, messageConfiguration, cancellationToken); @@ -113,7 +117,7 @@ public async Task> RequestAsync( { if (timeoutCts.IsCancellationRequested) { - MessagingLog.RequestTimedOut(_logger, type.FullName ?? type.Name, correlationId, DefaultTimeout.TotalSeconds); + MessagingLog.RequestTimedOut(_logger, urnString, correlationId, DefaultTimeout.TotalSeconds); tcs.TrySetResult(Result.Failure(Error.Failure("Messaging.Request.Timeout", $"Request timed out after {DefaultTimeout.TotalSeconds}s"))); } else @@ -124,7 +128,7 @@ public async Task> RequestAsync( var result = await tcs.Task; activity?.SetStatus(result.IsSuccess ? ActivityStatusCode.Ok : ActivityStatusCode.Error, result.IsSuccess ? null : result.Error.Description); - MessagingLog.RequestCompleted(_logger, type.FullName ?? type.Name, correlationId, result.IsSuccess); + MessagingLog.RequestCompleted(_logger, urnString, correlationId, result.IsSuccess); return result; } catch (Exception ex) diff --git a/src/Vulthil.Messaging.RabbitMq/Sending/RabbitMqSendEndpoint.cs b/src/Vulthil.Messaging.RabbitMq/Sending/RabbitMqSendEndpoint.cs index 77b800c..b587593 100644 --- a/src/Vulthil.Messaging.RabbitMq/Sending/RabbitMqSendEndpoint.cs +++ b/src/Vulthil.Messaging.RabbitMq/Sending/RabbitMqSendEndpoint.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.Logging; using RabbitMQ.Client; using Vulthil.Messaging.Abstractions.Publishers; +using Vulthil.Messaging.RabbitMq.Envelope; using Vulthil.Messaging.RabbitMq.Logging; using Vulthil.Messaging.RabbitMq.Publishing; using Vulthil.Messaging.RabbitMq.Requests; @@ -58,6 +59,8 @@ public async Task SendAsync( ?? messageConfiguration.CorrelationIdFormatter?.Invoke(message) ?? Guid.CreateVersion7().ToString(); var messageId = publishContext.MessageId ?? Guid.CreateVersion7().ToString(); + var urn = messageConfiguration.Urn; + var urnString = urn.AbsoluteUri; using var activity = MessagingInstrumentation.ActivitySource.StartActivity( $"{_queueName} send", @@ -69,14 +72,14 @@ public async Task SendAsync( activity.SetTag(MessagingInstrumentation.Tags.MessagingOperation, "send"); activity.SetTag(MessagingInstrumentation.Tags.MessagingDestination, _queueName); activity.SetTag(MessagingInstrumentation.Tags.MessagingRoutingKey, _queueName); - activity.SetTag(MessagingInstrumentation.Tags.MessageType, type.FullName); + activity.SetTag(MessagingInstrumentation.Tags.MessageType, urnString); activity.SetTag(MessagingInstrumentation.Tags.MessagingMessageId, messageId); activity.SetTag(MessagingInstrumentation.Tags.MessagingCorrelationId, correlationId); } var properties = new BasicProperties { - Type = type.FullName, + Type = urnString, MessageId = messageId, ReplyTo = PublishContext.ResolveRoutingKeyFromUri(publishContext.ResponseAddress), CorrelationId = correlationId, @@ -86,9 +89,11 @@ public async Task SendAsync( Timestamp = new AmqpTimestamp(DateTimeOffset.UtcNow.ToUnixTimeSeconds()), }; - var body = JsonSerializer.SerializeToUtf8Bytes(message, _messageConfigurationProvider.JsonSerializerOptions); + var envelope = MessageEnvelopeFactory.Create( + message, publishContext, messageId, correlationId, urn, _messageConfigurationProvider.JsonSerializerOptions); + var body = JsonSerializer.SerializeToUtf8Bytes(envelope, _messageConfigurationProvider.JsonSerializerOptions); - MessagingLog.Sending(_logger, type.FullName ?? type.Name, _queueName, messageId, correlationId); + MessagingLog.Sending(_logger, urnString, _queueName, messageId, correlationId); try { diff --git a/src/Vulthil.Messaging/IMessageConfigurationProvider.cs b/src/Vulthil.Messaging/IMessageConfigurationProvider.cs index a0d0c1d..e20a81e 100644 --- a/src/Vulthil.Messaging/IMessageConfigurationProvider.cs +++ b/src/Vulthil.Messaging/IMessageConfigurationProvider.cs @@ -23,6 +23,23 @@ public interface IMessageConfigurationProvider /// The resolved instance. MessageConfiguration GetMessageConfiguration() where TMessage : class; + /// + /// Gets the stable wire URN for the supplied message type. Equivalent to + /// GetMessageConfiguration(messageType).Urn — provided for clarity at call sites. + /// + /// The message CLR type. + /// The configured or default URN, e.g. urn:message:Acme.Orders:OrderPlaced. + Uri GetUrn(Type messageType); + + /// + /// Resolves a CLR type from its wire URN. Returns when no registered + /// type matches the supplied URN — receive-side callers must handle this (typically by dropping + /// the delivery as unknown). + /// + /// The URN as it appeared on the wire. + /// The registered CLR type, or if none. + Type? GetMessageType(Uri urn); + /// /// Gets the JSON serializer options used by the messaging system. /// diff --git a/src/Vulthil.Messaging/MessageConfiguration.cs b/src/Vulthil.Messaging/MessageConfiguration.cs index 27e415b..6b75754 100644 --- a/src/Vulthil.Messaging/MessageConfiguration.cs +++ b/src/Vulthil.Messaging/MessageConfiguration.cs @@ -2,15 +2,29 @@ namespace Vulthil.Messaging; /// /// Configuration used when publishing or binding a message type. -/// Contains exchange declaration settings and optional formatters for routing keys and correlation ids. +/// Contains exchange declaration settings, the stable wire URN, and optional formatters for routing keys and correlation ids. /// public record MessageConfiguration { /// /// Initializes a new for the specified exchange name. + /// The URN defaults to urn:message:<namespace>:<name> derived from the exchange (which itself + /// defaults to the CLR full type name). /// /// The name of the exchange to declare and bind for this message type. - public MessageConfiguration(string exchange) => Exchange = exchange; + public MessageConfiguration(string exchange) + { + Exchange = exchange; + Urn = DefaultUrnFromFullName(exchange); + } + + private static Uri DefaultUrnFromFullName(string fullName) + { + var lastDot = fullName.LastIndexOf('.'); + return lastDot < 0 + ? new($"urn:message:{fullName}") + : new($"urn:message:{fullName[..lastDot]}:{fullName[(lastDot + 1)..]}"); + } /// /// The name of the exchange to declare and bind for this message type. @@ -18,6 +32,13 @@ public record MessageConfiguration /// public string Exchange { get; set; } + /// + /// Stable wire identity for this message type, used in the message envelope's messageType field. + /// Defaults to urn:message:<namespace>:<name> when constructed via . + /// Override this to keep the wire identity stable across CLR renames. + /// + public Uri Urn { get; set; } + /// /// The exchange type to declare when creating the exchange (if applicable to the transport). /// @@ -60,7 +81,8 @@ public sealed record MessageConfiguration : MessageConfiguration where TMessage : class { /// - /// Initializes a new whose exchange defaults to the CLR full type name of . + /// Initializes a new whose exchange defaults to the CLR full type name of + /// and whose URN defaults to urn:message:<namespace>:<name> derived from . /// public MessageConfiguration() : base(typeof(TMessage).FullName!) { } diff --git a/src/Vulthil.Messaging/MessagingConfigurator.cs b/src/Vulthil.Messaging/MessagingConfigurator.cs index ac7f47d..dd6ac64 100644 --- a/src/Vulthil.Messaging/MessagingConfigurator.cs +++ b/src/Vulthil.Messaging/MessagingConfigurator.cs @@ -82,12 +82,14 @@ public IMessagingConfigurator ConfigureMessage(Action _typeConfigurations = []; + private readonly Dictionary _urnToType = []; private readonly HashSet _registeredRequestTypes = []; - internal Dictionary MessageConfigurations { get; } = new(StringComparer.Ordinal); - internal Dictionary QueueDefinitions { get; } = new(StringComparer.OrdinalIgnoreCase); + public JsonSerializerOptions JsonSerializerOptions { get; set; } = new(); + public TimeSpan DefaultTimeout { get; set; } = TimeSpan.FromSeconds(30); + public string FaultExchangeName { get; set; } = "Fault.Exchange"; + public ConsumeFilterOptions ConsumeFilters { get; } = new(); + /// public MessageConfiguration GetMessageConfiguration(Type messageType) { + if (_typeConfigurations.TryGetValue(messageType, out var cached)) + { + return cached; + } + var current = messageType; while (current != null && current != typeof(object)) { if (current.FullName is { } fullName && MessageConfigurations.TryGetValue(fullName, out var def)) { + RegisterMessageType(messageType, def); return def; } current = current.BaseType; } - return new MessageConfiguration(messageType.FullName!); + var fresh = new MessageConfiguration(messageType.FullName!); + RegisterMessageType(messageType, fresh); + return fresh; } /// public MessageConfiguration GetMessageConfiguration() where TMessage : class => GetMessageConfiguration(typeof(TMessage)); + /// + public Uri GetUrn(Type messageType) => GetMessageConfiguration(messageType).Urn; + + /// + public Type? GetMessageType(Uri urn) => _urnToType.GetValueOrDefault(urn); + /// IReadOnlyCollection IMessageConfigurationProvider.QueueDefinitions => QueueDefinitions.Values; internal bool RegisterRequestType(MessageType messageType) => _registeredRequestTypes.Add(messageType); + + /// + /// Records a CLR type ↔ mapping and updates the URN reverse index. + /// Idempotent on repeated calls for the same type; throws if two distinct types claim the same URN. + /// + internal void RegisterMessageType(Type type, MessageConfiguration configuration) + { + if (_typeConfigurations.TryGetValue(type, out var existing) && ReferenceEquals(existing, configuration)) + { + return; + } + + _typeConfigurations[type] = configuration; + + if (_urnToType.TryGetValue(configuration.Urn, out var owner)) + { + if (owner != type) + { + throw new InvalidOperationException( + $"URN '{configuration.Urn}' is already registered to type '{owner.FullName}'; cannot also register '{type.FullName}'. " + + "URNs must be unique across message types — override one via MessageConfiguration.Urn."); + } + return; + } + + _urnToType[configuration.Urn] = type; + } } diff --git a/src/Vulthil.Messaging/PublicAPI.Unshipped.txt b/src/Vulthil.Messaging/PublicAPI.Unshipped.txt index a9f343a..f87dcda 100644 --- a/src/Vulthil.Messaging/PublicAPI.Unshipped.txt +++ b/src/Vulthil.Messaging/PublicAPI.Unshipped.txt @@ -11,6 +11,8 @@ Vulthil.Messaging.IMessageConfigurationProvider.DefaultTimeout.get -> System.Tim Vulthil.Messaging.IMessageConfigurationProvider.FaultExchangeName.get -> string! Vulthil.Messaging.IMessageConfigurationProvider.GetMessageConfiguration(System.Type! messageType) -> Vulthil.Messaging.MessageConfiguration! Vulthil.Messaging.IMessageConfigurationProvider.GetMessageConfiguration() -> Vulthil.Messaging.MessageConfiguration! +Vulthil.Messaging.IMessageConfigurationProvider.GetMessageType(System.Uri! urn) -> System.Type? +Vulthil.Messaging.IMessageConfigurationProvider.GetUrn(System.Type! messageType) -> System.Uri! Vulthil.Messaging.IMessageConfigurationProvider.JsonSerializerOptions.get -> System.Text.Json.JsonSerializerOptions! Vulthil.Messaging.IMessageConfigurationProvider.QueueDefinitions.get -> System.Collections.Generic.IReadOnlyCollection! Vulthil.Messaging.IMessagingConfigurator @@ -45,6 +47,8 @@ Vulthil.Messaging.MessageConfiguration.ExchangeType.set -> void Vulthil.Messaging.MessageConfiguration.MessageConfiguration(string! exchange) -> void Vulthil.Messaging.MessageConfiguration.RoutingKeyFormatter.get -> System.Func? Vulthil.Messaging.MessageConfiguration.RoutingKeyFormatter.set -> void +Vulthil.Messaging.MessageConfiguration.Urn.get -> System.Uri! +Vulthil.Messaging.MessageConfiguration.Urn.set -> void Vulthil.Messaging.MessageConfiguration Vulthil.Messaging.MessageConfiguration.MessageConfiguration() -> void Vulthil.Messaging.MessageConfiguration.UseCorrelationId(System.Func! selector) -> Vulthil.Messaging.MessageConfiguration! diff --git a/tests/Vulthil.Messaging.RabbitMq.Tests/ConsumeFilterPipelineTests.cs b/tests/Vulthil.Messaging.RabbitMq.Tests/ConsumeFilterPipelineTests.cs index 2f57698..4f62b1d 100644 --- a/tests/Vulthil.Messaging.RabbitMq.Tests/ConsumeFilterPipelineTests.cs +++ b/tests/Vulthil.Messaging.RabbitMq.Tests/ConsumeFilterPipelineTests.cs @@ -6,6 +6,7 @@ using Vulthil.Messaging.Abstractions.Publishers; using Vulthil.Messaging.Queues; using Vulthil.Messaging.RabbitMq.Consumers; +using Vulthil.Messaging.RabbitMq.Envelope; using Vulthil.Messaging.RabbitMq.Requests; using Vulthil.xUnit; @@ -18,6 +19,7 @@ public sealed class ConsumeFilterPipelineTests : BaseUnitTestCase public ConsumeFilterPipelineTests() { + UseRealFor(); _lazyTarget = new Lazy(CreateInstance); } @@ -108,8 +110,8 @@ public async Task PipelineWithNoFiltersInvokesConsumerDirectly() var handler = Target.GetPlan(new MessageType(typeof(TestMessage)).Name)!.Handlers[0]; - // Act — consumer-kind handlers ignore the channel argument. - await handler.DispatchAsync(serviceProvider, new TestMessage("payload"), CreateDeliverEventArgs(), Mock.Of(), CancellationToken.None); + // Act + await handler.DispatchAsync(serviceProvider, new TestMessage("payload"), CreateDeliverEventArgs(), (MessageEnvelope?)null, Mock.Of(), CancellationToken.None); // Assert consumerInstance.Received.ShouldHaveSingleItem().Content.ShouldBe("payload"); @@ -141,8 +143,8 @@ public async Task PipelineComposesFiltersInRegistrationOrderOutermostFirst() var handler = Target.GetPlan(new MessageType(typeof(TestMessage)).Name)!.Handlers[0]; - // Act — consumer-kind handlers ignore the channel argument. - await handler.DispatchAsync(serviceProvider, new TestMessage("payload"), CreateDeliverEventArgs(), Mock.Of(), CancellationToken.None); + // Act + await handler.DispatchAsync(serviceProvider, new TestMessage("payload"), CreateDeliverEventArgs(), (MessageEnvelope?)null, Mock.Of(), CancellationToken.None); // Assert trace.ShouldBe(["outer:before", "inner:before", "inner:after", "outer:after"]); @@ -174,8 +176,8 @@ public async Task FilterShortCircuitPreventsConsumerInvocation() var handler = Target.GetPlan(new MessageType(typeof(TestMessage)).Name)!.Handlers[0]; - // Act — consumer-kind handlers ignore the channel argument. - await handler.DispatchAsync(serviceProvider, new TestMessage("payload"), CreateDeliverEventArgs(), Mock.Of(), CancellationToken.None); + // Act + await handler.DispatchAsync(serviceProvider, new TestMessage("payload"), CreateDeliverEventArgs(), (MessageEnvelope?)null, Mock.Of(), CancellationToken.None); // Assert trace.ShouldBe(["gate:before", "gate:short-circuit"]); @@ -228,6 +230,7 @@ await handler.DispatchAsync( serviceProvider, new TestRequest("query"), CreateDeliverEventArgs(replyTo: "reply", correlationId: "corr-1"), + (MessageEnvelope?)null, channel.Object, CancellationToken.None); @@ -288,6 +291,7 @@ await handler.DispatchAsync( serviceProvider, new TestRequest("query"), CreateDeliverEventArgs(replyTo: "reply"), + (MessageEnvelope?)null, channel.Object, CancellationToken.None); diff --git a/tests/Vulthil.Messaging.RabbitMq.Tests/MessageTypeCacheTests.cs b/tests/Vulthil.Messaging.RabbitMq.Tests/MessageTypeCacheTests.cs index edb82f5..c304c90 100644 --- a/tests/Vulthil.Messaging.RabbitMq.Tests/MessageTypeCacheTests.cs +++ b/tests/Vulthil.Messaging.RabbitMq.Tests/MessageTypeCacheTests.cs @@ -159,8 +159,8 @@ public async Task CompiledHandlerShouldCallConsumerWithCorrectMessage() var handler = plan!.Handlers[0]; var testMessage = new TestMessage("Hello, World!"); - // Act — consumer-kind handlers ignore the channel argument. - await handler.DispatchAsync(_serviceProvider, testMessage, CreateDeliverEventArgs(), Mock.Of(), CancellationToken.None); + // Act + await handler.DispatchAsync(_serviceProvider, testMessage, CreateDeliverEventArgs(), null, Mock.Of(), CancellationToken.None); // Assert consumerInstance.ReceivedMessages.ShouldHaveSingleItem(); @@ -211,7 +211,7 @@ public async Task CompiledRpcHandlerShouldCallConsumerAndPublishResponse() .Returns(ValueTask.CompletedTask); // Act - await handler.DispatchAsync(_serviceProvider, testRequest, deliveryArgs, channel.Object, CancellationToken.None); + await handler.DispatchAsync(_serviceProvider, testRequest, deliveryArgs, null, channel.Object, CancellationToken.None); // Assert consumerInstance.ReceivedRequests.ShouldHaveSingleItem(); @@ -271,6 +271,7 @@ await handler.DispatchAsync( _serviceProvider, new TestRequest("throw"), CreateDeliverEventArgs(replyTo: "reply.queue"), + null, channel.Object, CancellationToken.None); @@ -294,9 +295,7 @@ public void GetPlanShouldReturnNullForUnregisteredMessageType() [Fact] public void RegisterQueueShouldRecordEveryConsumerRegistration() { - // Arrange — two ConsumerRegistrations for the same message type on the same queue. The broker is - // authoritative for delivery; the plan records every handler and the worker dispatches each one - // on every delivery. Distinct routing-key semantics belong on distinct queues. + // Arrange var consumer = new ConsumerType(typeof(TestMessageConsumer)); var messageType = new MessageType(typeof(TestMessage)); @@ -332,8 +331,7 @@ public void RegisterQueueShouldRecordEveryConsumerRegistration() [Fact] public void RegisterQueueShouldRejectSecondRequestConsumerForSameMessageType() { - // Arrange — two request consumers for the same message type on the same queue would produce - // ambiguous responses, so registration must fail loudly. + // Arrange var first = new RequestConsumerRegistration { ConsumerType = new ConsumerType(typeof(TestRequestConsumer)), @@ -362,7 +360,7 @@ public void RegisterQueueShouldRejectSecondRequestConsumerForSameMessageType() [Fact] public void HandlerRoutingKeyFromFactoryShouldRoundTrip() { - // Arrange — exercises the typed-generic factory path directly. + // Arrange var handler = MessageHandlerFactory.ForRequestConsumer("custom.routing.key", retryPolicy: null); // Act & Assert diff --git a/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqPublisherExtendedTests.cs b/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqPublisherExtendedTests.cs index 5969c5f..d3cd7c5 100644 --- a/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqPublisherExtendedTests.cs +++ b/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqPublisherExtendedTests.cs @@ -5,9 +5,6 @@ namespace Vulthil.Messaging.RabbitMq.Tests; -/// -/// Represents the RabbitMqPublisherExtendedTests. -/// public sealed class RabbitMqPublisherExtendedTests : BaseUnitTestCase { private readonly Lazy _lazyTarget; @@ -16,9 +13,6 @@ public sealed class RabbitMqPublisherExtendedTests : BaseUnitTestCase private RabbitMqPublisher Target => _lazyTarget.Value; - /// - /// Executes this member. - /// public RabbitMqPublisherExtendedTests() { var logger = GetMock>().Object; @@ -42,9 +36,6 @@ public RabbitMqPublisherExtendedTests() _lazyTarget = new(CreateInstance); } - /// - /// Executes this member. - /// [Fact] public async Task PublishAsyncShouldSetMessageTypeInBasicProperties() { @@ -64,13 +55,11 @@ public async Task PublishAsyncShouldSetMessageTypeInBasicProperties() await Target.PublishAsync(message, cancellationToken: CancellationToken); // Assert + var expectedUrn = new MessageConfiguration(typeof(TestMessage).FullName!).Urn.AbsoluteUri; capturedProperties.ShouldNotBeNull(); - capturedProperties.Type.ShouldBe(typeof(TestMessage).FullName); + capturedProperties.Type.ShouldBe(expectedUrn); } - /// - /// Executes this member. - /// [Fact] public async Task PublishAsyncWithMultipleMessagesPublishesEach() { @@ -96,9 +85,6 @@ public async Task PublishAsyncWithMultipleMessagesPublishesEach() Times.Exactly(3)); } - /// - /// Executes this member. - /// [Fact] public async Task PublishAsyncShouldPublishToCorrectExchange() { @@ -123,9 +109,6 @@ public async Task PublishAsyncShouldPublishToCorrectExchange() private sealed class TestMessage { - /// - /// Gets or sets this member value. - /// public string Content { get; set; } = string.Empty; } } diff --git a/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqSendEndpointTests.cs b/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqSendEndpointTests.cs index 9ee41ed..8a2b0a4 100644 --- a/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqSendEndpointTests.cs +++ b/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqSendEndpointTests.cs @@ -1,15 +1,11 @@ using Microsoft.Extensions.Logging; using RabbitMQ.Client; -using Vulthil.Messaging.Abstractions.Publishers; using Vulthil.Messaging.RabbitMq.Publishing; using Vulthil.Messaging.RabbitMq.Sending; using Vulthil.xUnit; namespace Vulthil.Messaging.RabbitMq.Tests; -/// -/// Represents the RabbitMqSendEndpointTests. -/// public sealed class RabbitMqSendEndpointTests : BaseUnitTestCase { private const string QueueName = "order-commands"; @@ -21,9 +17,6 @@ public sealed class RabbitMqSendEndpointTests : BaseUnitTestCase private RabbitMqSendEndpoint Target => _lazyTarget.Value; - /// - /// Initializes test infrastructure. - /// public RabbitMqSendEndpointTests() { _publisherMock = GetMock(); @@ -47,9 +40,6 @@ public RabbitMqSendEndpointTests() logger)); } - /// - /// Verifies that the send path routes via the destination queue name and ignores the per-type exchange. - /// [Fact] public async Task SendAsyncShouldRouteToDestinationQueueByName() { @@ -68,9 +58,6 @@ public async Task SendAsyncShouldRouteToDestinationQueueByName() capturedQueue.ShouldBe(QueueName); } - /// - /// Verifies that the CorrelationIdFormatter on MessageConfiguration is applied when no explicit value is provided. - /// [Fact] public async Task SendAsyncShouldApplyCorrelationIdFormatter() { @@ -96,9 +83,6 @@ public async Task SendAsyncShouldApplyCorrelationIdFormatter() captured.CorrelationId.ShouldBe("correlation-from-formatter"); } - /// - /// Verifies that an explicit configureContext value wins over the formatter. - /// [Fact] public async Task SendAsyncShouldPreferExplicitCorrelationIdOverFormatter() { @@ -131,9 +115,6 @@ await Target.SendAsync( captured.CorrelationId.ShouldBe("explicit"); } - /// - /// Verifies that a null message throws ArgumentNullException. - /// [Fact] public async Task SendAsyncWithNullMessageThrows() { @@ -142,9 +123,6 @@ await Assert.ThrowsAsync( () => Target.SendAsync(null!, CancellationToken)); } - /// - /// Verifies that BasicProperties carries the CLR type name and the persistent flag. - /// [Fact] public async Task SendAsyncShouldPopulateBasicPropertiesType() { @@ -161,13 +139,13 @@ public async Task SendAsyncShouldPopulateBasicPropertiesType() // Assert captured.ShouldNotBeNull(); - captured.Type.ShouldBe(typeof(TestMessage).FullName); + var expectedUrn = new MessageConfiguration(typeof(TestMessage).FullName!).Urn.AbsoluteUri; + captured.Type.ShouldBe(expectedUrn); captured.Persistent.ShouldBeTrue(); } private sealed class TestMessage { - /// Gets or sets the content. public string Content { get; set; } = string.Empty; } } From faeacb4d631e4cab1f2c4e52a97106ce83e2c901 Mon Sep 17 00:00:00 2001 From: Vulthil Date: Fri, 29 May 2026 22:41:15 +0200 Subject: [PATCH 09/42] refactor(messaging): consolidate routing-key config; nullable Subscribe --- .github/copilot-instructions.md | 2 + .../documentation-guidelines.instructions.md | 7 +- Vulthil.SharedKernel.slnx | 1 + docs/articles/messaging.md | 156 ++++++++++++++++-- .../DependencyInjection.cs | 7 +- .../Consumers/RoutingKeyAttribute.cs | 14 -- .../PublicAPI.Unshipped.txt | 3 - .../Consumers/MessageHandler.cs | 5 +- .../Consumers/MessageHandlerFactory.cs | 6 +- .../Consumers/MessageTypeCache.cs | 62 ++++--- .../Consumers/RabbitMqConsumerWorker.cs | 20 ++- src/Vulthil.Messaging.RabbitMq/RabbitMqBus.cs | 9 +- .../RabbitMqConstants.cs | 32 ---- .../ConsumerHostedService.cs | 5 - .../MessagingConfigurator.cs | 1 + src/Vulthil.Messaging/PublicAPI.Unshipped.txt | 26 +-- .../Queues/BaseConfigurator.cs | 29 ++++ .../Queues/ConsumerConfigurator.cs | 88 +--------- .../Queues/IBaseConfigurator.cs | 14 ++ .../Queues/IConsumerConfigurator.cs | 12 ++ .../Queues/IQueueConfigurator.cs | 32 ++++ .../Queues/IRequestConfigurator.cs | 12 ++ .../Queues/QueueConfigurator.cs | 100 ++++++++++- .../Queues/QueueDefinition.cs | 39 ++++- .../Queues/RequestConsumerConfigurator.cs | 25 +-- .../Program.cs | 6 +- .../RoutingKeyAttributeTests.cs | 54 ------ .../ConsumeFilterPipelineTests.cs | 5 - .../MessageEnvelopeTests.cs | 74 +++++++++ .../MessageTypeCacheTests.cs | 38 +---- .../PolymorphicDispatchTests.cs | 117 +++++++++++++ .../ConsumerRegistrationTests.cs | 68 ++------ .../MessageConfigurationUrnTests.cs | 41 +++++ .../QueueConfiguratorBuildTests.cs | 141 ++++++++++++++++ .../QueueDefinitionTests.cs | 48 ------ 35 files changed, 863 insertions(+), 436 deletions(-) delete mode 100644 src/Vulthil.Messaging.Abstractions/Consumers/RoutingKeyAttribute.cs create mode 100644 src/Vulthil.Messaging/Queues/BaseConfigurator.cs create mode 100644 src/Vulthil.Messaging/Queues/IBaseConfigurator.cs create mode 100644 src/Vulthil.Messaging/Queues/IConsumerConfigurator.cs create mode 100644 src/Vulthil.Messaging/Queues/IRequestConfigurator.cs delete mode 100644 tests/Vulthil.Messaging.Abstractions.Tests/RoutingKeyAttributeTests.cs create mode 100644 tests/Vulthil.Messaging.RabbitMq.Tests/MessageEnvelopeTests.cs create mode 100644 tests/Vulthil.Messaging.RabbitMq.Tests/PolymorphicDispatchTests.cs create mode 100644 tests/Vulthil.Messaging.Tests/MessageConfigurationUrnTests.cs create mode 100644 tests/Vulthil.Messaging.Tests/QueueConfiguratorBuildTests.cs diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 28dd536..81da157 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -9,6 +9,7 @@ - When modifying a public member, make sure to update the XML Documentation and the corresponding docs in the docs folder and the README file if applicable. - When modifying a public member, make sure to check the Public.API files for the affected assembly and update them if necessary. - Do not ignore CS1591 warnings; analyze and add missing XML comments instead. +- Do not add comments inside methods; for complex logic, consider extracting it into a separate method with a descriptive name instead of adding comments. ## Testing Guidelines - Prefer using the Vulthil.xUnit testing framework for tests. @@ -17,6 +18,7 @@ - Prefer using the AutoMocker instance for dependency injection in tests to simplify test setup and improve readability. - Use the methods on the BaseUnitTestCase class for modifying the AutoMocker instance, such as `Use(T instance)` or `Use()` for registering dependencies, and `GetMock()` for retrieving mocks from the AutoMocker. - Override the CreateInstance or CreateInstance methods and use the Target property to lazily create the instance under test. +- Do not add comments inside test methods except Arrange, Act and Assert; rename the test method to be descriptive of the behavior being tested instead of adding comments. ## Documentation Guidelines - When generating package README files, keep them short and concise, and use the docs folder for more elaborate usage patterns. \ No newline at end of file diff --git a/.github/instructions/documentation-guidelines.instructions.md b/.github/instructions/documentation-guidelines.instructions.md index 344c6e9..f4ca32c 100644 --- a/.github/instructions/documentation-guidelines.instructions.md +++ b/.github/instructions/documentation-guidelines.instructions.md @@ -7,7 +7,10 @@ apply-to: "src/**/*.*" When assisting with this C# solution, you MUST adhere to the following documentation standards for all code modifications, additions, and refactors. Failure to maintain these standards will result in build warnings, CI/CD failures, or degraded documentation. ## 1. XML Documentation for Public Members -All `public` types and members (classes, structs, interfaces, records, enums, methods, properties, events, and fields) REQUIRE standard C# XML documentation comments (`///`). +Add standard C# XML documentation comments (`///`) only where the compiler would otherwise emit **CS1591** ("Missing XML comment for publicly visible type or member"). +In practice this covers `public` types and members (classes, structs, interfaces, records, enums, methods, properties, events, and fields) in assemblies that generate a documentation file. +Do not add `///` comments to members the compiler does not warn about (e.g., `internal`, `private`, or `protected` members, or members already covered by an enclosing ``), +as this adds noise without satisfying any analyzer requirement. * **Required Tags:** Always include a ``. * **Contextual Tags:** Include ``, ``, ``, ``, and `` tags wherever applicable. @@ -33,7 +36,7 @@ The conceptual documentation is housed in the `docs` folder and is built using D ## Agent Verification Checklist Before completing your task, verify the following: -- [ ] Did I add `///` comments to all newly created or modified `public` members? +- [ ] Did I add `///` comments to every newly created or modified member that would otherwise trigger CS1591 (and avoid adding them where the compiler does not warn)? - [ ] Did I add the exact API signature to `PublicAPI.Unshipped.txt`? - [ ] Did I add/update usage examples in the `docs` folder? - [ ] Are my conceptual doc changes compatible with `docs/docfx.json` and the Table of Contents? \ No newline at end of file diff --git a/Vulthil.SharedKernel.slnx b/Vulthil.SharedKernel.slnx index 8f31783..83d2869 100644 --- a/Vulthil.SharedKernel.slnx +++ b/Vulthil.SharedKernel.slnx @@ -44,6 +44,7 @@ + diff --git a/docs/articles/messaging.md b/docs/articles/messaging.md index 6d0646f..0cee11f 100644 --- a/docs/articles/messaging.md +++ b/docs/articles/messaging.md @@ -290,32 +290,42 @@ resolvable in unit tests if you want to assert against it. ## Routing Keys -Routing keys control which consumers receive a message on topic exchanges. +Routing keys flow through two distinct configuration sites, one on each side of the wire: -### Attribute-based routing +- **Producer side** — `MessageConfiguration.UseRoutingKey(selector)` controls the key the + publisher writes onto each outgoing message. +- **Consumer side** — `q.Subscribe(routingKey)` controls the binding pattern the broker + uses to filter deliveries for the queue. -```csharp -[RoutingKey("order.created")] -public sealed class OrderCreatedConsumer : IConsumer { ... } -``` - -### Dynamic routing keys +The two sites can use the same value (typical for direct exchanges) or different values (e.g. a topic +binding `order.*` matching producer keys like `order.created`). When the binding pattern is left null, +the broker receives an empty pattern: fanout/headers exchanges ignore it; direct/topic exchanges only +match an empty published key — supply a specific pattern explicitly when needed. ```csharp +// Producer side: what the publisher writes on the wire. messaging.ConfigureMessage(message => { message.UseRoutingKey(e => $"order.{e.Region}"); message.UseCorrelationId(e => e.OrderId.ToString()); }); + +// Consumer side: how the queue binds. Pattern matches the producer's published key. +messaging.ConfigureQueue("order-projector", q => +{ + q.Subscribe("order.*"); + q.AddConsumer(); +}); ``` ## Message Configuration Each message type is associated with a `MessageConfiguration` that controls the -exchange name, exchange type, durability, and routing/correlation formatters used -when publishing. The `Exchange` defaults to the message CLR full type name when -constructed via `MessageConfiguration`; the publisher and bus topology -share that same source of truth, so they never get out of sync. +exchange name, exchange type, durability, routing/correlation formatters used +when publishing, and the stable wire URN. The `Exchange` defaults to the message +CLR full type name when constructed via `MessageConfiguration`; the +publisher and bus topology share that same source of truth, so they never get +out of sync. ```csharp messaging.ConfigureMessage(m => @@ -327,6 +337,128 @@ messaging.ConfigureMessage(m => }); ``` +### Wire identity (URN) + +Every message type carries a stable wire URN that identifies it on the broker +independent of its CLR type name. The default is derived from the CLR type: + +``` +urn:message:Acme.Orders:OrderCreatedEvent +``` + +Override it via `MessageConfiguration.Urn` to keep the wire identity stable +across CLR renames — producers and consumers on different versions agree on the +URN even if their C# class names diverge: + +```csharp +messaging.ConfigureMessage(m => +{ + m.Urn = new Uri("urn:message:Acme.Orders:OrderPlaced"); +}); +``` + +The URN is the dispatch key on the receive side — it appears in the message +envelope's `messageType` field, and `MessageTypeCache` keys its execution plans +by URN. + +### Message envelope (wire format) + +Vulthil-produced messages are wrapped in a JSON envelope with explicit metadata +fields rather than relying on AMQP `BasicProperties` headers: + +```json +{ + "messageId": "01931d7e-...", + "correlationId": "a3f1...", + "conversationId": "a3f1...", + "initiatorId": "01931d7d-...", + "sourceAddress": "queue:order-service", + "messageType": "urn:message:Acme.Orders:OrderPlaced", + "message": { "orderId": "abc", "amount": 100 }, + "sentTime": "2026-05-27T12:00:00Z", + "headers": { "tenant": "acme" } +} +``` + +`BasicProperties.MessageId`, `CorrelationId`, and `Type` (= URN) are still +mirrored into AMQP for broker tooling and trace propagation, but the envelope +is the source of truth. + +External producers that emit bare JSON (no envelope) are accepted on the +receive path — the worker probes the body and falls back to using +`BasicProperties.Type` as the type identity. + +## Subscriptions and Polymorphism + +Topology and dispatch are separated: + +- **Subscriptions** = exchange bindings. A queue is subscribed to a concrete + message type via `q.Subscribe()`; at topology setup time, the + queue's binding to `TConcrete`'s exchange is declared. +- **Consumers** = handlers. A consumer is registered via + `q.AddConsumer()` where `TConsumer` implements + `IConsumer` — `TMessage` may be a concrete type, an interface, or + an abstract base. + +When `TMessage` is **concrete**, the consumer's message type is auto-subscribed +at build time — preserves the today's ergonomic for the simple case: + +```csharp +m.ConfigureQueue("orders", q => q.AddConsumer()); +// → q is auto-subscribed to OrderCreatedEvent. +``` + +When `TMessage` is **polymorphic** (an interface or abstract base), the +consumer fires for any concrete delivery whose CLR type is assignable to it — +but the queue must explicitly subscribe to the concrete types it wants to bind: + +```csharp +// OrderProjector : IConsumer +m.ConfigureQueue("order-projector", q => +{ + q.Subscribe(); // bind queue to OrderPlaced exchange + q.Subscribe(); // bind queue to OrderCancelled exchange + q.AddConsumer(); // fires on either delivery +}); +``` + +`SubscribeAll(Assembly)` discovers concrete implementers in the +supplied assembly and calls `Subscribe` for each — abstract types +and interfaces are skipped (only concrete types have exchanges): + +```csharp +m.ConfigureQueue("order-projector", q => +{ + q.SubscribeAll(typeof(OrderPlaced).Assembly); + q.AddConsumer(); +}); +``` + +Dispatch is transitive: with a hierarchy `OrderPlaced : IOrder : IOrderEvent`, +a single `OrderPlaced` delivery fires consumers registered against the concrete +`OrderPlaced`, against `IOrder` (immediate interface), and against +`IOrderEvent` (transitive interface). + +### Validation at composition time + +After `ConfigureQueue` returns, a build pass validates the queue's wiring and +throws with a descriptive message if anything is incoherent: + +- A consumer with no matching concrete subscription (e.g. `IConsumer` + but no `Subscribe` for any implementer). +- A subscription with no matching consumer. +- A request consumer registered against an abstract or interface request type + (responses are typed and can't depend on the concrete request type). + +These failures surface at app startup, not at the first message delivery. + +### Request consumers stay non-polymorphic + +`IRequestConsumer` requires a concrete `TRequest` — the +response type is fixed and can't be selected by the incoming concrete type. +Each `(queue, message type)` pair admits at most one request consumer; a +second one fails fast at composition. + `MessageConfiguration` instances can also come from configuration — see below. ## Configuration-driven Setup diff --git a/samples/WebApi/WebApi.Infrastructure/DependencyInjection.cs b/samples/WebApi/WebApi.Infrastructure/DependencyInjection.cs index 43644de..274f889 100644 --- a/samples/WebApi/WebApi.Infrastructure/DependencyInjection.cs +++ b/samples/WebApi/WebApi.Infrastructure/DependencyInjection.cs @@ -41,15 +41,12 @@ public static IHostApplicationBuilder AddRabbitMqMessagingInfrastructure(this IH queue.AddRequestConsumer(); + queue.Subscribe("main-entity.created"); + queue.Subscribe("main-entity.created2"); queue.AddConsumer(c => { - c.Bind("main-entity.created"); c.UseRetry(r => r.Immediate(5)); }); - queue.AddConsumer(c => - { - c.Bind("main-entity.created2"); - }); }); x.UseRabbitMq(rabbitMqConnectionStringKey); diff --git a/src/Vulthil.Messaging.Abstractions/Consumers/RoutingKeyAttribute.cs b/src/Vulthil.Messaging.Abstractions/Consumers/RoutingKeyAttribute.cs deleted file mode 100644 index 5fa06d0..0000000 --- a/src/Vulthil.Messaging.Abstractions/Consumers/RoutingKeyAttribute.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace Vulthil.Messaging.Abstractions.Consumers; - -/// -/// Specifies a routing key pattern to use when binding a consumer to an exchange. -/// -/// The routing key pattern (e.g., "#", "Order.*"). -[AttributeUsage(AttributeTargets.Class, AllowMultiple = false)] -public sealed class RoutingKeyAttribute(string pattern) : Attribute -{ - /// - /// Gets the routing key pattern used for exchange binding (e.g., "#", "Order.*"). - /// - public string Pattern { get; } = pattern; -} diff --git a/src/Vulthil.Messaging.Abstractions/PublicAPI.Unshipped.txt b/src/Vulthil.Messaging.Abstractions/PublicAPI.Unshipped.txt index 0a71d8c..a61d297 100644 --- a/src/Vulthil.Messaging.Abstractions/PublicAPI.Unshipped.txt +++ b/src/Vulthil.Messaging.Abstractions/PublicAPI.Unshipped.txt @@ -42,9 +42,6 @@ Vulthil.Messaging.Abstractions.Consumers.IMessageContext.Message.get - Vulthil.Messaging.Abstractions.Consumers.IRequestConsumer Vulthil.Messaging.Abstractions.Consumers.IRequestConsumer Vulthil.Messaging.Abstractions.Consumers.IRequestConsumer.ConsumeAsync(Vulthil.Messaging.Abstractions.Consumers.IMessageContext! messageContext, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! -Vulthil.Messaging.Abstractions.Consumers.RoutingKeyAttribute -Vulthil.Messaging.Abstractions.Consumers.RoutingKeyAttribute.Pattern.get -> string! -Vulthil.Messaging.Abstractions.Consumers.RoutingKeyAttribute.RoutingKeyAttribute(string! pattern) -> void Vulthil.Messaging.Abstractions.Publishers.IPublishContext Vulthil.Messaging.Abstractions.Publishers.IPublishContext.AddHeader(string! key, object? value) -> void Vulthil.Messaging.Abstractions.Publishers.IPublishContext.AddHeaders(System.Collections.Generic.IDictionary! headers) -> void diff --git a/src/Vulthil.Messaging.RabbitMq/Consumers/MessageHandler.cs b/src/Vulthil.Messaging.RabbitMq/Consumers/MessageHandler.cs index 4c344a1..f35ab51 100644 --- a/src/Vulthil.Messaging.RabbitMq/Consumers/MessageHandler.cs +++ b/src/Vulthil.Messaging.RabbitMq/Consumers/MessageHandler.cs @@ -13,10 +13,7 @@ namespace Vulthil.Messaging.RabbitMq.Consumers; /// internal sealed record MessageHandler { - /// The routing key (or topic pattern) the handler was registered with. Used for diagnostics; the broker is authoritative for delivery. - public required string RoutingKey { get; init; } - - /// The retry policy applied by the worker when this handler throws. + /// The retry policy applied by the worker when this handler's plan throws. public RetryPolicyDefinition? RetryPolicy { get; init; } /// The consumer contract the handler implements. diff --git a/src/Vulthil.Messaging.RabbitMq/Consumers/MessageHandlerFactory.cs b/src/Vulthil.Messaging.RabbitMq/Consumers/MessageHandlerFactory.cs index fd38495..4c15538 100644 --- a/src/Vulthil.Messaging.RabbitMq/Consumers/MessageHandlerFactory.cs +++ b/src/Vulthil.Messaging.RabbitMq/Consumers/MessageHandlerFactory.cs @@ -20,12 +20,11 @@ internal static class MessageHandlerFactory /// /// Builds a handler for a one-way . /// - public static MessageHandler ForConsumer(string routingKey, RetryPolicyDefinition? retryPolicy) + public static MessageHandler ForConsumer(RetryPolicyDefinition? retryPolicy) where TConsumer : class, IConsumer where TMessage : notnull => new() { - RoutingKey = routingKey, RetryPolicy = retryPolicy, Kind = HandlerKind.Consumer, DispatchAsync = async (sp, message, ea, envelope, _, ct) => @@ -48,13 +47,12 @@ public static MessageHandler ForConsumer(string routingKey, /// /// Builds a handler for a request/reply . /// - public static MessageHandler ForRequestConsumer(string routingKey, RetryPolicyDefinition? retryPolicy) + public static MessageHandler ForRequestConsumer(RetryPolicyDefinition? retryPolicy) where TConsumer : class, IRequestConsumer where TRequest : notnull where TResponse : notnull => new() { - RoutingKey = routingKey, RetryPolicy = retryPolicy, Kind = HandlerKind.RequestConsumer, DispatchAsync = async (sp, message, ea, envelope, channel, ct) => diff --git a/src/Vulthil.Messaging.RabbitMq/Consumers/MessageTypeCache.cs b/src/Vulthil.Messaging.RabbitMq/Consumers/MessageTypeCache.cs index be6dedc..86303db 100644 --- a/src/Vulthil.Messaging.RabbitMq/Consumers/MessageTypeCache.cs +++ b/src/Vulthil.Messaging.RabbitMq/Consumers/MessageTypeCache.cs @@ -9,8 +9,8 @@ internal sealed class MessageTypeCache private readonly IMessageConfigurationProvider _provider; private readonly Dictionary _plansByUrn = []; private readonly Dictionary _plansByFullName = new(StringComparer.Ordinal); - private readonly ConcurrentDictionary<(Type Consumer, Type Message), Func> _consumerFactoryCache = new(); - private readonly ConcurrentDictionary<(Type Consumer, Type Request, Type Response), Func> _requestConsumerFactoryCache = new(); + private readonly ConcurrentDictionary<(Type Consumer, Type Message), Func> _consumerFactoryCache = new(); + private readonly ConcurrentDictionary<(Type Consumer, Type Request, Type Response), Func> _requestConsumerFactoryCache = new(); private static readonly MethodInfo _forConsumerMethod = typeof(MessageHandlerFactory) .GetMethod(nameof(MessageHandlerFactory.ForConsumer), BindingFlags.Public | BindingFlags.Static) @@ -26,45 +26,59 @@ public MessageTypeCache(IMessageConfigurationProvider provider) public void RegisterQueue(QueueDefinition queue) { - foreach (var consumer in queue.Registrations.OfType()) + var effectiveSubscriptions = new HashSet(queue.Subscriptions.Select(s => s.MessageType)); + var concreteRegistrationTypes = queue.Registrations + .Select(r => r.MessageType) + .Where(m => m.Type is { IsAbstract: false, IsInterface: false }); + foreach (var concrete in concreteRegistrationTypes) { - var msgType = consumer.MessageType; - var plan = GetOrAddPlan(msgType); - - var factory = GetConsumerFactory(consumer.ConsumerType.Type, msgType.Type); - var handler = factory(RabbitMqConstants.GetRoutingKey(consumer), consumer.RetryPolicy); - plan.Handlers.Add(handler); + effectiveSubscriptions.Add(concrete); } - foreach (var rpc in queue.Registrations.OfType()) + foreach (var subscription in effectiveSubscriptions) { - var msgType = rpc.MessageType; - var plan = GetOrAddPlan(msgType); + var concreteType = subscription.Type; + var plan = GetOrAddPlan(subscription); - if (plan.Handlers.Any(h => h.Kind == HandlerKind.RequestConsumer)) + foreach (var registration in queue.Registrations) { - throw new InvalidOperationException( - $"Queue '{queue.Name}' already has a request consumer registered for message type '{msgType.Name}'. " + - "A message type can have at most one request consumer per queue, since multiple responses would be ambiguous."); + if (!registration.MessageType.Type.IsAssignableFrom(concreteType)) + { + continue; + } + + if (registration is RequestConsumerRegistration rpc) + { + if (plan.Handlers.Any(h => h.Kind == HandlerKind.RequestConsumer)) + { + throw new InvalidOperationException( + $"Queue '{queue.Name}' already has a request consumer registered for message type '{subscription.Name}'. " + + "A message type can have at most one request consumer per queue, since multiple responses would be ambiguous."); + } + + var rpcFactory = GetRequestConsumerFactory(rpc.ConsumerType.Type, rpc.MessageType.Type, rpc.ResponseType); + plan.Handlers.Add(rpcFactory(rpc.RetryPolicy)); + } + else + { + var consumerFactory = GetConsumerFactory(registration.ConsumerType.Type, registration.MessageType.Type); + plan.Handlers.Add(consumerFactory(registration.RetryPolicy)); + } } - - var factory = GetRequestConsumerFactory(rpc.ConsumerType.Type, msgType.Type, rpc.ResponseType); - var handler = factory(RabbitMqConstants.GetRoutingKey(rpc), rpc.RetryPolicy); - plan.Handlers.Add(handler); } } - private Func GetConsumerFactory(Type consumerType, Type messageType) + private Func GetConsumerFactory(Type consumerType, Type messageType) => _consumerFactoryCache.GetOrAdd((consumerType, messageType), static key => _forConsumerMethod .MakeGenericMethod(key.Consumer, key.Message) - .CreateDelegate>()); + .CreateDelegate>()); - private Func GetRequestConsumerFactory(Type consumerType, Type requestType, Type responseType) + private Func GetRequestConsumerFactory(Type consumerType, Type requestType, Type responseType) => _requestConsumerFactoryCache.GetOrAdd((consumerType, requestType, responseType), static key => _forRequestConsumerMethod .MakeGenericMethod(key.Consumer, key.Request, key.Response) - .CreateDelegate>()); + .CreateDelegate>()); private MessageExecutionPlan GetOrAddPlan(MessageType messageType) { diff --git a/src/Vulthil.Messaging.RabbitMq/Consumers/RabbitMqConsumerWorker.cs b/src/Vulthil.Messaging.RabbitMq/Consumers/RabbitMqConsumerWorker.cs index 9759ed9..7e26f42 100644 --- a/src/Vulthil.Messaging.RabbitMq/Consumers/RabbitMqConsumerWorker.cs +++ b/src/Vulthil.Messaging.RabbitMq/Consumers/RabbitMqConsumerWorker.cs @@ -97,7 +97,8 @@ private async Task OnMessageReceivedAsync(object sender, BasicDeliverEventArgs e private async Task HandleFailureAsync(Exception ex, BasicDeliverEventArgs ea, string messageTypeName) { - var policy = GetPolicy(ea.RoutingKey, _queueDefinition); + var plan = _typeCache.GetPlan(messageTypeName); + var policy = GetPolicy(plan, _queueDefinition); var headers = ea.BasicProperties.Headers ?? new Dictionary(); int currentRetry = RabbitMqConstants.GetRetryCount(headers); @@ -170,13 +171,18 @@ private async Task PublishFaultIfRequestedAsync(Exception ex, BasicDeliverEventA } } - private static RetryPolicyDefinition? GetPolicy(string routingKey, QueueDefinition queue) + private static RetryPolicyDefinition? GetPolicy(MessageExecutionPlan? plan, QueueDefinition queue) { - var registration = queue.Registrations - .FirstOrDefault(r => RabbitMqConstants.GetRoutingKey(r) == routingKey); - - return registration?.RetryPolicy - ?? queue.DefaultRetryPolicy; + if (plan is not null) + { + var registration = queue.Registrations + .FirstOrDefault(r => r.MessageType.Type == plan.MessageType.Type && r.RetryPolicy is not null); + if (registration?.RetryPolicy is { } policy) + { + return policy; + } + } + return queue.DefaultRetryPolicy; } private async Task HandleMessageAsync(BasicDeliverEventArgs ea) diff --git a/src/Vulthil.Messaging.RabbitMq/RabbitMqBus.cs b/src/Vulthil.Messaging.RabbitMq/RabbitMqBus.cs index 5b77ee8..784c1ab 100644 --- a/src/Vulthil.Messaging.RabbitMq/RabbitMqBus.cs +++ b/src/Vulthil.Messaging.RabbitMq/RabbitMqBus.cs @@ -156,14 +156,13 @@ await channel.QueueDeclareAsync( await channel.QueueBindAsync( queue: queue.Name, exchange: queue.Name, - routingKey: "#", + routingKey: string.Empty, cancellationToken: cancellationToken); - foreach (var registration in queue.Registrations) + foreach (var subscription in queue.Subscriptions) { - var messageConfig = _messageConfigurationProvider.GetMessageConfiguration(registration.MessageType.Type); + var messageConfig = _messageConfigurationProvider.GetMessageConfiguration(subscription.MessageType.Type); var exchangeName = messageConfig.Exchange; - var bindingPattern = RabbitMqConstants.GetRoutingKey(registration); await channel.ExchangeDeclareAsync( exchange: exchangeName, @@ -176,7 +175,7 @@ await channel.ExchangeDeclareAsync( await channel.ExchangeBindAsync( destination: queue.Name, source: exchangeName, - routingKey: bindingPattern, + routingKey: subscription.RoutingKey ?? string.Empty, cancellationToken: cancellationToken); } } diff --git a/src/Vulthil.Messaging.RabbitMq/RabbitMqConstants.cs b/src/Vulthil.Messaging.RabbitMq/RabbitMqConstants.cs index 0e89186..a416de2 100644 --- a/src/Vulthil.Messaging.RabbitMq/RabbitMqConstants.cs +++ b/src/Vulthil.Messaging.RabbitMq/RabbitMqConstants.cs @@ -1,20 +1,11 @@ -using System.Reflection; using System.Text; -using Vulthil.Messaging.Abstractions.Consumers; -using Vulthil.Messaging.Queues; namespace Vulthil.Messaging.RabbitMq; internal static class RabbitMqConstants { - /// - /// Represents this member. - /// public const string ContentType = "application/json"; - /// - /// Executes this member. - /// public static string? GetMetadata(Type type, object message, IReadOnlyDictionary> registry) { var current = type; @@ -30,15 +21,6 @@ internal static class RabbitMqConstants return null; } - /// - /// Executes this member. - /// - public static string GetRoutingKey(Registration registration) => - registration.ConsumerType.Type.GetCustomAttribute()?.Pattern ?? registration.RoutingKey; - - /// - /// Executes this member. - /// public static int GetRetryCount(IDictionary? headers) { if (headers?.TryGetValue("x-retry-count", out var countObj) == true) @@ -54,22 +36,16 @@ public static int GetRetryCount(IDictionary? headers) return 0; } - /// - /// Executes this member. - /// public static DateTimeOffset? TryParseExpiration(string? expiration) { - // RabbitMQ stores expiration as a string representing milliseconds if (!string.IsNullOrWhiteSpace(expiration) && long.TryParse(expiration, out var ms)) { try { - // We calculate expiration relative to 'now' when we received it return DateTimeOffset.UtcNow.AddMilliseconds(ms); } catch (ArgumentOutOfRangeException) { - // Handle cases where ms might be too large for DateTimeOffset return DateTimeOffset.MaxValue; } } @@ -77,9 +53,6 @@ public static int GetRetryCount(IDictionary? headers) return null; } - /// - /// Executes this member. - /// public static string? GetHeaderString(IDictionary headers, string key) { if (headers.TryGetValue(key, out var value) && value is byte[] bytes) @@ -90,9 +63,6 @@ public static int GetRetryCount(IDictionary? headers) return value?.ToString(); } - /// - /// Executes this member. - /// public static Uri? GetHeaderUri(IDictionary headers, string key) { var str = GetHeaderString(headers, key); @@ -101,8 +71,6 @@ public static int GetRetryCount(IDictionary? headers) return null; } - // Try to parse as absolute (e.g., amqp://broker/queue) - // or fall back to a custom scheme (e.g., queue:my-reply-queue) return Uri.TryCreate(str, UriKind.Absolute, out var uri) ? uri : new Uri($"queue:{str}"); diff --git a/src/Vulthil.Messaging/ConsumerHostedService.cs b/src/Vulthil.Messaging/ConsumerHostedService.cs index 4ef9a38..eea3191 100644 --- a/src/Vulthil.Messaging/ConsumerHostedService.cs +++ b/src/Vulthil.Messaging/ConsumerHostedService.cs @@ -6,15 +6,10 @@ internal sealed class ConsumerHostedService : BackgroundService { private readonly ITransport _transport; - /// - /// Initializes a new instance with the specified transport. - /// - /// The transport responsible for consuming messages. public ConsumerHostedService(ITransport transport) { _transport = transport; } - /// protected override Task ExecuteAsync(CancellationToken stoppingToken) => _transport.StartAsync(stoppingToken); } diff --git a/src/Vulthil.Messaging/MessagingConfigurator.cs b/src/Vulthil.Messaging/MessagingConfigurator.cs index dd6ac64..2daa8ee 100644 --- a/src/Vulthil.Messaging/MessagingConfigurator.cs +++ b/src/Vulthil.Messaging/MessagingConfigurator.cs @@ -47,6 +47,7 @@ public IMessagingConfigurator ConfigureQueue(string queueName, Action Vulthil.Messaging.Messagin Vulthil.Messaging.MessagingExchangeType.Fanout = 0 -> Vulthil.Messaging.MessagingExchangeType Vulthil.Messaging.MessagingExchangeType.Headers = 3 -> Vulthil.Messaging.MessagingExchangeType Vulthil.Messaging.MessagingExchangeType.Topic = 2 -> Vulthil.Messaging.MessagingExchangeType -Vulthil.Messaging.Queues.BaseConfigurator -Vulthil.Messaging.Queues.BaseConfigurator.BaseConfigurator() -> void -Vulthil.Messaging.Queues.BaseConfigurator.UseRetry(System.Action! value) -> void +Vulthil.Messaging.Queues.BaseConfigurator +Vulthil.Messaging.Queues.BaseConfigurator.BaseConfigurator() -> void +Vulthil.Messaging.Queues.BaseConfigurator.Self.get -> TConfigurator! +Vulthil.Messaging.Queues.BaseConfigurator.UseRetry(System.Action! value) -> TConfigurator! Vulthil.Messaging.Queues.ConsumerConfigurator -Vulthil.Messaging.Queues.ConsumerConfigurator.Bind(string! routingKey) -> Vulthil.Messaging.Queues.IConsumerConfigurator! Vulthil.Messaging.Queues.ConsumerConfigurator.ConsumerConfigurator() -> void Vulthil.Messaging.Queues.ConsumerRegistration Vulthil.Messaging.Queues.ConsumerType @@ -78,18 +78,18 @@ Vulthil.Messaging.Queues.DeadLetterDefinition.ExchangeName.get -> string? Vulthil.Messaging.Queues.DeadLetterDefinition.ExchangeName.set -> void Vulthil.Messaging.Queues.DeadLetterDefinition.QueueName.get -> string? Vulthil.Messaging.Queues.DeadLetterDefinition.QueueName.set -> void +Vulthil.Messaging.Queues.IBaseConfigurator +Vulthil.Messaging.Queues.IBaseConfigurator.UseRetry(System.Action! value) -> TConfigurator Vulthil.Messaging.Queues.IConsumerConfigurator -Vulthil.Messaging.Queues.IConsumerConfigurator.Bind(string! routingKey) -> Vulthil.Messaging.Queues.IConsumerConfigurator! -Vulthil.Messaging.Queues.IConsumerConfigurator.UseRetry(System.Action! value) -> void Vulthil.Messaging.Queues.IQueueConfigurator Vulthil.Messaging.Queues.IQueueConfigurator.AddConsumer(System.Action!>? configure = null) -> Vulthil.Messaging.Queues.IQueueConfigurator! Vulthil.Messaging.Queues.IQueueConfigurator.AddRequestConsumer(System.Action!>? configure = null) -> Vulthil.Messaging.Queues.IQueueConfigurator! Vulthil.Messaging.Queues.IQueueConfigurator.ConfigureQueue(System.Action! configureAction) -> Vulthil.Messaging.Queues.IQueueConfigurator! +Vulthil.Messaging.Queues.IQueueConfigurator.Subscribe(string? routingKey = null) -> Vulthil.Messaging.Queues.IQueueConfigurator! +Vulthil.Messaging.Queues.IQueueConfigurator.SubscribeAll(System.Reflection.Assembly! assembly, string? routingKey = null) -> Vulthil.Messaging.Queues.IQueueConfigurator! Vulthil.Messaging.Queues.IQueueConfigurator.UseDeadLetterQueue(string? queueName = null, string? exchangeName = null) -> Vulthil.Messaging.Queues.IQueueConfigurator! Vulthil.Messaging.Queues.IQueueConfigurator.UseRetry(System.Action! configure) -> Vulthil.Messaging.Queues.IQueueConfigurator! Vulthil.Messaging.Queues.IRequestConfigurator -Vulthil.Messaging.Queues.IRequestConfigurator.Bind(string! routingKey) -> Vulthil.Messaging.Queues.IRequestConfigurator! -Vulthil.Messaging.Queues.IRequestConfigurator.UseRetry(System.Action! value) -> void Vulthil.Messaging.Queues.MessageType Vulthil.Messaging.Queues.MessageType.MessageType(System.Type! Type) -> void Vulthil.Messaging.Queues.MessageType.Name.get -> string! @@ -125,6 +125,13 @@ Vulthil.Messaging.Queues.QueueDefinition.PrefetchCount.get -> ushort Vulthil.Messaging.Queues.QueueDefinition.PrefetchCount.set -> void Vulthil.Messaging.Queues.QueueDefinition.QueueDefinition(string! Name) -> void Vulthil.Messaging.Queues.QueueDefinition.Registrations.get -> System.Collections.Generic.IReadOnlyCollection! +Vulthil.Messaging.Queues.QueueDefinition.Subscriptions.get -> System.Collections.Generic.IReadOnlyCollection! +Vulthil.Messaging.Queues.Subscription +Vulthil.Messaging.Queues.Subscription.MessageType.get -> Vulthil.Messaging.Queues.MessageType! +Vulthil.Messaging.Queues.Subscription.MessageType.init -> void +Vulthil.Messaging.Queues.Subscription.RoutingKey.get -> string? +Vulthil.Messaging.Queues.Subscription.RoutingKey.init -> void +Vulthil.Messaging.Queues.Subscription.Subscription(Vulthil.Messaging.Queues.MessageType! MessageType, string? RoutingKey = null) -> void Vulthil.Messaging.Queues.QueueDefinition.RetryEnabled.get -> bool Vulthil.Messaging.Queues.Registration Vulthil.Messaging.Queues.Registration.ConsumerType.get -> Vulthil.Messaging.Queues.ConsumerType! @@ -133,10 +140,7 @@ Vulthil.Messaging.Queues.Registration.MessageType.get -> Vulthil.Messaging.Queue Vulthil.Messaging.Queues.Registration.MessageType.init -> void Vulthil.Messaging.Queues.Registration.RetryPolicy.get -> Vulthil.Messaging.Queues.RetryPolicyDefinition? Vulthil.Messaging.Queues.Registration.RetryPolicy.init -> void -Vulthil.Messaging.Queues.Registration.RoutingKey.get -> string! -Vulthil.Messaging.Queues.Registration.RoutingKey.init -> void Vulthil.Messaging.Queues.RequestConsumerConfigurator -Vulthil.Messaging.Queues.RequestConsumerConfigurator.Bind(string! routingKey) -> Vulthil.Messaging.Queues.IRequestConfigurator! Vulthil.Messaging.Queues.RequestConsumerConfigurator.RequestConsumerConfigurator() -> void Vulthil.Messaging.Queues.RequestConsumerRegistration Vulthil.Messaging.Queues.RequestConsumerRegistration.ResponseType.get -> System.Type! diff --git a/src/Vulthil.Messaging/Queues/BaseConfigurator.cs b/src/Vulthil.Messaging/Queues/BaseConfigurator.cs new file mode 100644 index 0000000..21ed2ac --- /dev/null +++ b/src/Vulthil.Messaging/Queues/BaseConfigurator.cs @@ -0,0 +1,29 @@ +namespace Vulthil.Messaging.Queues; + +/// +/// Base class for fluent consumer configurators. Provides the shared retry-policy storage and a single +/// implementation that returns the derived configurator's interface type, so +/// concrete configurator classes can inherit and remain empty. +/// +/// The derived configurator interface (e.g. ). +public abstract class BaseConfigurator : IBaseConfigurator + where TConfigurator : class, IBaseConfigurator +{ + internal RetryPolicyDefinition? RetryPolicy { get; private set; } + + /// + public TConfigurator UseRetry(Action value) + { + var configurator = new RetryPolicyConfigurator(); + value(configurator); + RetryPolicy = configurator.Build(); + return Self; + } + + /// + /// Returns this typed as . Safe by construction: + /// every concrete subclass of implements + /// per the class-level constraint. + /// + protected TConfigurator Self => (TConfigurator)(IBaseConfigurator)this; +} diff --git a/src/Vulthil.Messaging/Queues/ConsumerConfigurator.cs b/src/Vulthil.Messaging/Queues/ConsumerConfigurator.cs index b614a8d..5dac96c 100644 --- a/src/Vulthil.Messaging/Queues/ConsumerConfigurator.cs +++ b/src/Vulthil.Messaging/Queues/ConsumerConfigurator.cs @@ -3,88 +3,10 @@ namespace Vulthil.Messaging.Queues; /// -/// Base class for consumer configurators providing shared retry policy support. -/// -public abstract class BaseConfigurator -{ - internal Dictionary Overrides { get; } = []; - - internal RetryPolicyDefinition? RetryPolicy { get; private set; } - - /// - /// Configures a retry policy for this consumer. - /// - /// An action to configure the retry policy. - public void UseRetry(Action value) - { - var _retryPolicyConfigurator = new RetryPolicyConfigurator(); - value(_retryPolicyConfigurator); - RetryPolicy = _retryPolicyConfigurator.Build(); - } -} - -/// -/// Provides per-consumer configuration for routing key overrides and retry policies. -/// -/// The consumer type being configured. -public sealed class ConsumerConfigurator : BaseConfigurator, IConsumerConfigurator where TConsumer : IConsumer -{ - /// - public IConsumerConfigurator Bind(string routingKey) - where TMessage : notnull - { - if (!typeof(TConsumer).IsAssignableTo(typeof(IConsumer))) - { - throw new ArgumentException( - $"Registration Error: '{typeof(TConsumer).Name}' cannot bind to '{typeof(TMessage).Name}' " + - $"because it does not implement IConsumer<{typeof(TMessage).Name}>."); - } - - Overrides[new(typeof(TMessage))] = routingKey; - return this; - } - -} - -/// -/// Configures per-consumer routing key overrides and retry policies. +/// Concrete consumer configurator. Inherits +/// returning — no body needed. /// /// The consumer type. -public interface IConsumerConfigurator where TConsumer : IConsumer -{ - /// - /// Binds a message type to a routing key for the configured consumer. - /// - /// The message type to bind. - /// The routing key to use for the message type. - /// The current configurator instance. - IConsumerConfigurator Bind(string routingKey) - where TMessage : notnull; - /// - /// Configures a retry policy for this consumer. - /// - /// An action to configure the retry policy. - void UseRetry(Action value); -} -/// -/// Configures per-consumer routing key overrides for request/reply consumers. -/// -/// The request consumer type. -public interface IRequestConfigurator where TConsumer : IRequestConsumer -{ - /// - /// Binds a request/response message pair to a routing key for the configured request consumer. - /// - /// The request message type. - /// The response message type. - /// The routing key to use for the request binding. - /// The current request configurator instance. - IRequestConfigurator Bind(string routingKey) - where TRequest : notnull - where TResponse : notnull; - /// - /// Configures a retry policy for this request consumer. - /// - /// An action to configure the retry policy. - void UseRetry(Action value); -} +public sealed class ConsumerConfigurator + : BaseConfigurator>, IConsumerConfigurator + where TConsumer : IConsumer; diff --git a/src/Vulthil.Messaging/Queues/IBaseConfigurator.cs b/src/Vulthil.Messaging/Queues/IBaseConfigurator.cs new file mode 100644 index 0000000..e5d8e7f --- /dev/null +++ b/src/Vulthil.Messaging/Queues/IBaseConfigurator.cs @@ -0,0 +1,14 @@ +namespace Vulthil.Messaging.Queues; + +/// +/// Self-typed base interface for fluent consumer configurators. +/// is the derived configurator interface (e.g. ), so +/// returns it directly — concrete configurator classes inherit +/// without needing their own explicit interface implementations. +/// +public interface IBaseConfigurator + where TConfigurator : IBaseConfigurator +{ + /// Configures a retry policy and returns the typed configurator for chaining. + TConfigurator UseRetry(Action value); +} diff --git a/src/Vulthil.Messaging/Queues/IConsumerConfigurator.cs b/src/Vulthil.Messaging/Queues/IConsumerConfigurator.cs new file mode 100644 index 0000000..43b1ed7 --- /dev/null +++ b/src/Vulthil.Messaging/Queues/IConsumerConfigurator.cs @@ -0,0 +1,12 @@ +using Vulthil.Messaging.Abstractions.Consumers; + +namespace Vulthil.Messaging.Queues; + +/// +/// Configures per-consumer settings. Binding patterns live on +/// ; routing-key formatters for the producer side +/// live on . +/// +/// The consumer type; reserved on the interface to keep parity with and to leave a hook for future per-consumer-type knobs. +public interface IConsumerConfigurator : IBaseConfigurator> + where TConsumer : IConsumer; diff --git a/src/Vulthil.Messaging/Queues/IQueueConfigurator.cs b/src/Vulthil.Messaging/Queues/IQueueConfigurator.cs index 098c113..651c7ea 100644 --- a/src/Vulthil.Messaging/Queues/IQueueConfigurator.cs +++ b/src/Vulthil.Messaging/Queues/IQueueConfigurator.cs @@ -1,3 +1,4 @@ +using System.Reflection; using System.Security.Cryptography; using Vulthil.Messaging.Abstractions.Consumers; @@ -10,12 +11,43 @@ public interface IQueueConfigurator { /// /// Registers a one-way consumer on this queue with optional per-consumer configuration. + /// If the consumer's TMessage is concrete, the queue is auto-subscribed to it at build time; + /// for polymorphic consumers (e.g. IConsumer<IOrderEvent>) the caller must explicitly + /// the concrete implementers. /// IQueueConfigurator AddConsumer(Action>? configure = null) where TConsumer : class, IConsumer; /// /// Registers a request/reply consumer on this queue with optional per-consumer configuration. /// IQueueConfigurator AddRequestConsumer(Action>? configure = null) where TConsumer : class, IRequestConsumer; + + /// + /// Subscribes this queue to receive deliveries of the concrete message type . + /// At topology setup time, the queue is bound to 's exchange with the supplied + /// pattern (the broker uses this to filter; the worker does not re-filter). + /// Abstract types and interfaces are rejected — those have no exchange; use + /// or call for each concrete implementer instead. + /// + /// A concrete (non-abstract, non-interface) message type. + /// + /// The binding pattern. When , the broker receives an empty string — + /// fanout/headers exchanges ignore it; direct exchanges only deliver messages with an empty + /// published routing key; topic exchanges match no patterns. For non-empty needs, supply a specific + /// pattern (e.g. "order.created" for direct, "order.*" for topic). + /// + IQueueConfigurator Subscribe(string? routingKey = null) where TMessage : class; + + /// + /// Discovers every concrete (non-abstract, non-interface) type in that is + /// assignable to , and calls for each. + /// Pair with a polymorphic AddConsumer<TConsumer>() (where TConsumer : IConsumer<TInterface>) + /// to dispatch all implementers through one consumer. + /// + /// The polymorphic dispatch type — typically an interface or abstract base class. + /// The assembly to scan for concrete implementers. + /// The binding pattern applied to every discovered implementer's exchange. = broker default. + IQueueConfigurator SubscribeAll(Assembly assembly, string? routingKey = null) where TInterface : class; + /// /// Applies additional configuration to the underlying . /// diff --git a/src/Vulthil.Messaging/Queues/IRequestConfigurator.cs b/src/Vulthil.Messaging/Queues/IRequestConfigurator.cs new file mode 100644 index 0000000..bfdd1e8 --- /dev/null +++ b/src/Vulthil.Messaging/Queues/IRequestConfigurator.cs @@ -0,0 +1,12 @@ +using Vulthil.Messaging.Abstractions.Consumers; + +namespace Vulthil.Messaging.Queues; + +/// +/// Configures per-request-consumer settings. Binding patterns live on +/// ; routing-key formatters for the producer side +/// live on . +/// +/// The request consumer type; reserved on the interface to keep parity with and future per-consumer-type knobs. +public interface IRequestConfigurator : IBaseConfigurator> + where TConsumer : IRequestConsumer; diff --git a/src/Vulthil.Messaging/Queues/QueueConfigurator.cs b/src/Vulthil.Messaging/Queues/QueueConfigurator.cs index cbe0d85..7c62758 100644 --- a/src/Vulthil.Messaging/Queues/QueueConfigurator.cs +++ b/src/Vulthil.Messaging/Queues/QueueConfigurator.cs @@ -1,3 +1,4 @@ +using System.Reflection; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Vulthil.Messaging.Abstractions.Consumers; @@ -35,14 +36,10 @@ public IQueueConfigurator AddConsumer(Action(Action(Action + public IQueueConfigurator Subscribe(string? routingKey = null) where TMessage : class + => SubscribeCore(typeof(TMessage), routingKey); + + /// + public IQueueConfigurator SubscribeAll(Assembly assembly, string? routingKey = null) where TInterface : class + { + ArgumentNullException.ThrowIfNull(assembly); + + foreach (var concrete in assembly.GetTypes() + .Where(t => t is { IsAbstract: false, IsInterface: false } && typeof(TInterface).IsAssignableFrom(t))) + { + SubscribeCore(concrete, routingKey); + } + return this; + } + + private QueueConfigurator SubscribeCore(Type concrete, string? routingKey) + { + if (concrete.IsAbstract || concrete.IsInterface) + { + throw new InvalidOperationException( + $"Cannot Subscribe to abstract or interface type '{concrete.FullName}'. " + + "Only concrete message types have exchanges; use SubscribeAll(assembly) to discover implementers " + + "or call Subscribe() for each one."); + } + + _messagingOptions.GetMessageConfiguration(concrete); + _queueDefinition.AddSubscription(new Subscription(new MessageType(concrete), routingKey)); + return this; + } + /// public IQueueConfigurator UseRetry(Action configure) { @@ -114,4 +140,64 @@ public IQueueConfigurator UseDeadLetterQueue(string? queueName = null, string? e return this; } + + /// + /// Final resolution pass — runs once after the user's configurator action completes. Auto-subscribes + /// any concrete TMessage from consumer registrations that wasn't explicitly subscribed, and validates + /// that every consumer has at least one matching concrete subscription and every concrete subscription + /// has at least one matching consumer. Request consumers must target concrete types. + /// + /// + /// Thrown when a consumer has no matching subscription, a subscription has no matching consumer, + /// or a request consumer is registered against an abstract/interface request type. + /// + internal void Build() + { + // 1. Auto-subscribe any concrete TMessage from consumer registrations not yet subscribed. + // Registrations no longer carry a routing key — that's a Subscription-level concern — so auto-subscribed + // subscriptions get a null routing key (broker uses an empty pattern). For direct/topic exchanges that + // require a specific pattern, the caller must explicitly q.Subscribe("pattern") first. + var concreteConsumerMessageTypes = _queueDefinition.Registrations + .Select(r => r.MessageType) + .Where(m => m.Type is { IsAbstract: false, IsInterface: false }); + foreach (var messageType in concreteConsumerMessageTypes) + { + _messagingOptions.GetMessageConfiguration(messageType.Type); + _queueDefinition.AddSubscription(new Subscription(messageType)); + } + + // 2. Request consumers cannot be polymorphic — the response type is fixed and can't be selected + // by the incoming concrete type. + foreach (var rpc in _queueDefinition.Registrations.OfType()) + { + var t = rpc.MessageType.Type; + if (t.IsAbstract || t.IsInterface) + { + throw new InvalidOperationException( + $"Queue '{_queueDefinition.Name}': request consumer '{rpc.ConsumerType.Name}' has polymorphic request type '{t.FullName}'. " + + "Request consumers must use a concrete request type since the response is typed."); + } + } + + // 3. Every consumer must have at least one matching concrete subscription. + var orphanConsumer = _queueDefinition.Registrations.FirstOrDefault( + r => !_queueDefinition.Subscriptions.Any(s => r.MessageType.Type.IsAssignableFrom(s.MessageType.Type))); + if (orphanConsumer is not null) + { + throw new InvalidOperationException( + $"Queue '{_queueDefinition.Name}': consumer '{orphanConsumer.ConsumerType.Name}' targets message type '{orphanConsumer.MessageType.Type.FullName}' " + + "but no concrete subscribed type on this queue is assignable to it. " + + "Call q.Subscribe() or q.SubscribeAll(assembly) for at least one implementer."); + } + + // 4. Every subscription must have at least one matching consumer. + var orphanSubscription = _queueDefinition.Subscriptions.FirstOrDefault( + s => !_queueDefinition.Registrations.Any(r => r.MessageType.Type.IsAssignableFrom(s.MessageType.Type))); + if (orphanSubscription is not null) + { + throw new InvalidOperationException( + $"Queue '{_queueDefinition.Name}': concrete subscription '{orphanSubscription.MessageType.Type.FullName}' has no matching consumer. " + + "Either AddConsumer() that handles this message type, or remove the subscription."); + } + } } diff --git a/src/Vulthil.Messaging/Queues/QueueDefinition.cs b/src/Vulthil.Messaging/Queues/QueueDefinition.cs index 4d86c95..4243a7b 100644 --- a/src/Vulthil.Messaging/Queues/QueueDefinition.cs +++ b/src/Vulthil.Messaging/Queues/QueueDefinition.cs @@ -24,7 +24,9 @@ public sealed record ConsumerType(Type Type) } /// -/// Base record for a consumer-to-message binding within a queue. +/// Base record for a consumer registration on a queue. Routing-key patterns no longer live here — +/// they belong on (queue→exchange binding) or +/// (producer-side routing key). /// public abstract record Registration { @@ -36,10 +38,6 @@ public abstract record Registration /// Gets the message type that this consumer is registered to handle. /// public required MessageType MessageType { get; init; } - /// - /// Gets the routing key pattern used to filter messages for this binding. Default is "#" (match all). - /// - public string RoutingKey { get; init; } = "#"; /// /// Gets the per-consumer retry policy, or to inherit the queue-level default. @@ -52,6 +50,19 @@ public abstract record Registration /// public sealed record ConsumerRegistration : Registration; +/// +/// Binds the queue to a concrete message type's exchange. One = one +/// exchange→queue binding declared at topology setup time. +/// +/// The concrete message type whose exchange will be bound. +/// +/// The binding pattern used by the broker to filter deliveries. = the broker +/// receives an empty pattern: fanout/headers exchanges ignore it, direct/topic exchanges only match +/// messages published with an empty (direct) or unmatchable (topic) routing key. Supply a specific +/// pattern (e.g. "order.created" for direct, "order.*" for topic) when needed. +/// +public sealed record Subscription(MessageType MessageType, string? RoutingKey = null); + /// /// A consumer registration for request/reply message consumption. /// @@ -70,6 +81,7 @@ public sealed record RequestConsumerRegistration : Registration public sealed record QueueDefinition(string Name) { private readonly HashSet _registrations = []; + private readonly HashSet _subscriptions = []; /// /// Gets or sets the default retry policy applied to all consumers on this queue. @@ -142,6 +154,20 @@ public sealed record QueueDefinition(string Name) _registrations.ToList().AsReadOnly(); #endif + /// + /// Gets the exchange bindings configured for this queue — each becomes one + /// exchange→queue binding declared at topology setup time. Populated by + /// and , + /// and auto-populated by Build for any consumer whose concrete TMessage isn't explicitly subscribed + /// (the consumer's routing-key pattern is carried into the subscription). + /// + public IReadOnlyCollection Subscriptions => +#if NET10_0_OR_GREATER + _subscriptions.AsReadOnly(); +#else + _subscriptions.ToList().AsReadOnly(); +#endif + /// /// Gets a value indicating whether any retry policy is configured, either at the queue or consumer level. /// @@ -150,4 +176,7 @@ public sealed record QueueDefinition(string Name) internal void AddConsumer(Registration registration) => _registrations.Add(registration); + + internal void AddSubscription(Subscription subscription) + => _subscriptions.Add(subscription); } diff --git a/src/Vulthil.Messaging/Queues/RequestConsumerConfigurator.cs b/src/Vulthil.Messaging/Queues/RequestConsumerConfigurator.cs index 6951248..2682719 100644 --- a/src/Vulthil.Messaging/Queues/RequestConsumerConfigurator.cs +++ b/src/Vulthil.Messaging/Queues/RequestConsumerConfigurator.cs @@ -3,24 +3,9 @@ namespace Vulthil.Messaging.Queues; /// -/// Provides per-consumer configuration for request/reply routing key overrides and retry policies. +/// Concrete request-consumer configurator. Inherits +/// returning — no body needed. /// -/// The request consumer type being configured. -public class RequestConsumerConfigurator : BaseConfigurator, IRequestConfigurator where TConsumer : IRequestConsumer -{ - /// - public IRequestConfigurator Bind(string routingKey) - where TRequest : notnull - where TResponse : notnull - { - if (!typeof(TConsumer).IsAssignableTo(typeof(IRequestConsumer))) - { - throw new ArgumentException( - $"Registration Error: '{typeof(TConsumer).Name}' cannot bind to request '{typeof(TRequest).Name}' " + - $"because it does not implement IRequestConsumer<{typeof(TRequest).Name}, {typeof(TResponse).Name}>."); - } - - Overrides[new(typeof(TRequest))] = routingKey; - return this; - } -} +public sealed class RequestConsumerConfigurator + : BaseConfigurator>, IRequestConfigurator + where TConsumer : IRequestConsumer; diff --git a/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ConsumerService/Program.cs b/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ConsumerService/Program.cs index 3a808f8..710eb15 100644 --- a/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ConsumerService/Program.cs +++ b/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ConsumerService/Program.cs @@ -39,12 +39,14 @@ messaging.ConfigureQueue("weather-commands", queue => { - queue.AddConsumer(c => c.Bind("weather.record")); + queue.Subscribe("weather.record"); + queue.AddConsumer(); }); messaging.ConfigureQueue("weather-requests", queue => { - queue.AddRequestConsumer(c => c.Bind("weather.get")); + queue.Subscribe("weather.get"); + queue.AddRequestConsumer(); }); messaging.UseRabbitMq("rabbitmq"); diff --git a/tests/Vulthil.Messaging.Abstractions.Tests/RoutingKeyAttributeTests.cs b/tests/Vulthil.Messaging.Abstractions.Tests/RoutingKeyAttributeTests.cs deleted file mode 100644 index f59a586..0000000 --- a/tests/Vulthil.Messaging.Abstractions.Tests/RoutingKeyAttributeTests.cs +++ /dev/null @@ -1,54 +0,0 @@ -using Vulthil.Messaging.Abstractions.Consumers; -using Vulthil.xUnit; - -namespace Vulthil.Messaging.Abstractions.Tests; - -/// -/// Represents the RoutingKeyAttributeTests. -/// -public sealed class RoutingKeyAttributeTests : BaseUnitTestCase -{ - /// - /// Executes this member. - /// - [Fact] - public void RoutingKeyAttributeShouldStorePattern() - { - // Arrange & Act - var attribute = new RoutingKeyAttribute("test.pattern"); - - // Assert - attribute.Pattern.ShouldBe("test.pattern"); - } - - /// - /// Executes this member. - /// - [Fact] - public void RoutingKeyAttributeShouldBeApplicableToClass() - { - // Arrange & Act - var attribute = typeof(RoutingKeyAttribute).GetCustomAttributes(typeof(AttributeUsageAttribute), false).FirstOrDefault() as AttributeUsageAttribute; - - // Assert - attribute.ShouldNotBeNull(); - attribute!.ValidOn.ShouldBe(AttributeTargets.Class); - } - - /// - /// Executes this member. - /// - [Fact] - public void ClassCanHaveRoutingKeyAttribute() - { - // Arrange & Act - var attributes = typeof(TestConsumerWithAttribute).GetCustomAttributes(typeof(RoutingKeyAttribute), false); - - // Assert - attributes.Length.ShouldBe(1); - ((RoutingKeyAttribute)attributes[0]).Pattern.ShouldBe("order.*"); - } - - [RoutingKey("order.*")] - private class TestConsumerWithAttribute { } -} diff --git a/tests/Vulthil.Messaging.RabbitMq.Tests/ConsumeFilterPipelineTests.cs b/tests/Vulthil.Messaging.RabbitMq.Tests/ConsumeFilterPipelineTests.cs index 4f62b1d..37a3e37 100644 --- a/tests/Vulthil.Messaging.RabbitMq.Tests/ConsumeFilterPipelineTests.cs +++ b/tests/Vulthil.Messaging.RabbitMq.Tests/ConsumeFilterPipelineTests.cs @@ -104,7 +104,6 @@ public async Task PipelineWithNoFiltersInvokesConsumerDirectly() { ConsumerType = new ConsumerType(typeof(RecordingConsumer)), MessageType = new MessageType(typeof(TestMessage)), - RoutingKey = "#" }); Target.RegisterQueue(queue); @@ -137,7 +136,6 @@ public async Task PipelineComposesFiltersInRegistrationOrderOutermostFirst() { ConsumerType = new ConsumerType(typeof(RecordingConsumer)), MessageType = new MessageType(typeof(TestMessage)), - RoutingKey = "#" }); Target.RegisterQueue(queue); @@ -170,7 +168,6 @@ public async Task FilterShortCircuitPreventsConsumerInvocation() { ConsumerType = new ConsumerType(typeof(RecordingConsumer)), MessageType = new MessageType(typeof(TestMessage)), - RoutingKey = "#" }); Target.RegisterQueue(queue); @@ -204,7 +201,6 @@ public async Task RpcPipelineComposesFiltersAroundConsumerCall() ConsumerType = new ConsumerType(typeof(RecordingRequestConsumer)), MessageType = new MessageType(typeof(TestRequest)), ResponseType = typeof(TestResponse), - RoutingKey = "#" }); Target.RegisterQueue(queue); @@ -265,7 +261,6 @@ public async Task RpcPipelineShortCircuitProducesFailureResponse() ConsumerType = new ConsumerType(typeof(RecordingRequestConsumer)), MessageType = new MessageType(typeof(TestRequest)), ResponseType = typeof(TestResponse), - RoutingKey = "#" }); Target.RegisterQueue(queue); diff --git a/tests/Vulthil.Messaging.RabbitMq.Tests/MessageEnvelopeTests.cs b/tests/Vulthil.Messaging.RabbitMq.Tests/MessageEnvelopeTests.cs new file mode 100644 index 0000000..3ca70af --- /dev/null +++ b/tests/Vulthil.Messaging.RabbitMq.Tests/MessageEnvelopeTests.cs @@ -0,0 +1,74 @@ +using System.Text.Json; +using Vulthil.Messaging.RabbitMq.Envelope; +using Vulthil.xUnit; + +namespace Vulthil.Messaging.RabbitMq.Tests; + +public sealed class MessageEnvelopeTests : BaseUnitTestCase +{ + private static JsonSerializerOptions Options => new() { WriteIndented = false }; + + [Fact] + public void EnvelopeRoundtripsThroughJson() + { + // Arrange + var original = new MessageEnvelope + { + MessageId = "msg-1", + RequestId = "req-1", + CorrelationId = "corr-1", + ConversationId = "conv-1", + InitiatorId = "init-1", + SourceAddress = "queue:producer", + DestinationAddress = "queue:fulfillment", + ResponseAddress = "queue:reply", + FaultAddress = "queue:faults", + MessageType = new Uri("urn:message:Acme.Orders:OrderPlaced"), + Message = JsonSerializer.SerializeToElement(new { orderId = "abc", amount = 42 }), + SentTime = DateTimeOffset.FromUnixTimeSeconds(1_700_000_000), + ExpirationTime = DateTimeOffset.FromUnixTimeSeconds(1_700_000_300), + Headers = new Dictionary { ["tenant"] = "acme" }, + }; + + // Act + var json = JsonSerializer.SerializeToUtf8Bytes(original, Options); + var roundtripped = JsonSerializer.Deserialize(json, Options); + + // Assert + roundtripped.ShouldNotBeNull(); + roundtripped.MessageId.ShouldBe("msg-1"); + roundtripped.RequestId.ShouldBe("req-1"); + roundtripped.CorrelationId.ShouldBe("corr-1"); + roundtripped.ConversationId.ShouldBe("conv-1"); + roundtripped.InitiatorId.ShouldBe("init-1"); + roundtripped.SourceAddress.ShouldBe("queue:producer"); + roundtripped.DestinationAddress.ShouldBe("queue:fulfillment"); + roundtripped.ResponseAddress.ShouldBe("queue:reply"); + roundtripped.FaultAddress.ShouldBe("queue:faults"); + roundtripped.MessageType.AbsoluteUri.ShouldBe("urn:message:Acme.Orders:OrderPlaced"); + roundtripped.Message.GetProperty("orderId").GetString().ShouldBe("abc"); + roundtripped.Message.GetProperty("amount").GetInt32().ShouldBe(42); + roundtripped.SentTime.ShouldBe(DateTimeOffset.FromUnixTimeSeconds(1_700_000_000)); + roundtripped.ExpirationTime.ShouldBe(DateTimeOffset.FromUnixTimeSeconds(1_700_000_300)); + roundtripped.Headers.ShouldNotBeNull(); + roundtripped.Headers["tenant"]!.ToString().ShouldBe("acme"); + } + + [Fact] + public void EnvelopeSerializesWithLowerCamelCasePropertyNames() + { + // Arrange + var envelope = new MessageEnvelope + { + MessageType = new Uri("urn:message:T"), + Message = JsonSerializer.SerializeToElement(new { x = 1 }), + }; + + // Act + var json = JsonSerializer.Serialize(envelope, Options); + + // Assert + json.ShouldContain("\"messageType\"", Case.Sensitive); + json.ShouldContain("\"message\"", Case.Sensitive); + } +} diff --git a/tests/Vulthil.Messaging.RabbitMq.Tests/MessageTypeCacheTests.cs b/tests/Vulthil.Messaging.RabbitMq.Tests/MessageTypeCacheTests.cs index c304c90..25be4fc 100644 --- a/tests/Vulthil.Messaging.RabbitMq.Tests/MessageTypeCacheTests.cs +++ b/tests/Vulthil.Messaging.RabbitMq.Tests/MessageTypeCacheTests.cs @@ -94,7 +94,6 @@ public void RegisterQueueShouldRegisterStandardConsumers() { ConsumerType = consumer, MessageType = messageType, - RoutingKey = "test.message" }; var queue = new QueueDefinition("TestQueue"); queue.AddConsumer(registration); @@ -107,7 +106,6 @@ public void RegisterQueueShouldRegisterStandardConsumers() plan.ShouldNotBeNull(); plan.Handlers.ShouldHaveSingleItem(); plan.Handlers[0].Kind.ShouldBe(HandlerKind.Consumer); - plan.Handlers[0].RoutingKey.ShouldBe("test.message"); } [Fact] @@ -121,7 +119,6 @@ public void RegisterQueueShouldRegisterRequestConsumers() ConsumerType = consumer, MessageType = messageType, ResponseType = typeof(TestResponse), - RoutingKey = "test.request" }; var queue = new QueueDefinition("TestQueue"); queue.AddConsumer(registration); @@ -132,8 +129,7 @@ public void RegisterQueueShouldRegisterRequestConsumers() // Assert var plan = Target.GetPlan(messageType.Name); plan.ShouldNotBeNull(); - var rpcHandler = plan.Handlers.Single(h => h.Kind == HandlerKind.RequestConsumer); - rpcHandler.RoutingKey.ShouldBe("test.request"); + plan.Handlers.ShouldContain(h => h.Kind == HandlerKind.RequestConsumer); } [Fact] @@ -149,7 +145,6 @@ public async Task CompiledHandlerShouldCallConsumerWithCorrectMessage() { ConsumerType = consumer, MessageType = messageType, - RoutingKey = "#" }; var queue = new QueueDefinition("TestQueue"); queue.AddConsumer(registration); @@ -181,7 +176,6 @@ public async Task CompiledRpcHandlerShouldCallConsumerAndPublishResponse() ConsumerType = consumer, MessageType = messageType, ResponseType = typeof(TestResponse), - RoutingKey = "#" }; var queue = new QueueDefinition("TestQueue"); queue.AddConsumer(registration); @@ -240,7 +234,6 @@ public async Task CompiledRpcHandlerShouldPublishFailureWhenConsumerThrows() ConsumerType = new ConsumerType(typeof(ThrowingRequestConsumer)), MessageType = new MessageType(typeof(TestRequest)), ResponseType = typeof(TestResponse), - RoutingKey = "#" }; var queue = new QueueDefinition("TestQueue"); @@ -293,24 +286,14 @@ public void GetPlanShouldReturnNullForUnregisteredMessageType() } [Fact] - public void RegisterQueueShouldRecordEveryConsumerRegistration() + public void RegisterQueueShouldDedupeIdenticalRegistrationsIntoOneHandler() { // Arrange var consumer = new ConsumerType(typeof(TestMessageConsumer)); var messageType = new MessageType(typeof(TestMessage)); - var registration1 = new ConsumerRegistration - { - ConsumerType = consumer, - MessageType = messageType, - RoutingKey = "route.1" - }; - var registration2 = new ConsumerRegistration - { - ConsumerType = consumer, - MessageType = messageType, - RoutingKey = "route.2" - }; + var registration1 = new ConsumerRegistration { ConsumerType = consumer, MessageType = messageType }; + var registration2 = new ConsumerRegistration { ConsumerType = consumer, MessageType = messageType }; var queue = new QueueDefinition("TestQueue"); queue.AddConsumer(registration1); @@ -322,10 +305,8 @@ public void RegisterQueueShouldRecordEveryConsumerRegistration() // Assert var plan = Target.GetPlan(messageType.Name); plan.ShouldNotBeNull(); - plan.Handlers.Count.ShouldBe(2); - plan.Handlers[0].RoutingKey.ShouldBe("route.1"); - plan.Handlers[1].RoutingKey.ShouldBe("route.2"); - plan.Handlers.ShouldAllBe(h => h.Kind == HandlerKind.Consumer); + plan.Handlers.ShouldHaveSingleItem(); + plan.Handlers[0].Kind.ShouldBe(HandlerKind.Consumer); } [Fact] @@ -337,14 +318,12 @@ public void RegisterQueueShouldRejectSecondRequestConsumerForSameMessageType() ConsumerType = new ConsumerType(typeof(TestRequestConsumer)), MessageType = new MessageType(typeof(TestRequest)), ResponseType = typeof(TestResponse), - RoutingKey = "test.request" }; var second = new RequestConsumerRegistration { ConsumerType = new ConsumerType(typeof(ThrowingRequestConsumer)), MessageType = new MessageType(typeof(TestRequest)), ResponseType = typeof(TestResponse), - RoutingKey = "test.request.alt" }; var queue = new QueueDefinition("TestQueue"); @@ -358,13 +337,12 @@ public void RegisterQueueShouldRejectSecondRequestConsumerForSameMessageType() } [Fact] - public void HandlerRoutingKeyFromFactoryShouldRoundTrip() + public void HandlerFromFactoryShouldCarryKind() { // Arrange - var handler = MessageHandlerFactory.ForRequestConsumer("custom.routing.key", retryPolicy: null); + var handler = MessageHandlerFactory.ForRequestConsumer(retryPolicy: null); // Act & Assert - handler.RoutingKey.ShouldBe("custom.routing.key"); handler.Kind.ShouldBe(HandlerKind.RequestConsumer); } } diff --git a/tests/Vulthil.Messaging.RabbitMq.Tests/PolymorphicDispatchTests.cs b/tests/Vulthil.Messaging.RabbitMq.Tests/PolymorphicDispatchTests.cs new file mode 100644 index 0000000..3ff9514 --- /dev/null +++ b/tests/Vulthil.Messaging.RabbitMq.Tests/PolymorphicDispatchTests.cs @@ -0,0 +1,117 @@ +using Microsoft.Extensions.DependencyInjection; +using RabbitMQ.Client; +using Vulthil.Messaging.Abstractions.Consumers; +using Vulthil.Messaging.Abstractions.Publishers; +using Vulthil.Messaging.Queues; +using Vulthil.Messaging.RabbitMq.Consumers; +using Vulthil.Messaging.RabbitMq.Envelope; +using Vulthil.xUnit; + +namespace Vulthil.Messaging.RabbitMq.Tests; + +public sealed class PolymorphicDispatchTests : BaseUnitTestCase +{ + private readonly Lazy _lazyTarget; + private MessageTypeCache Target => _lazyTarget.Value; + + public PolymorphicDispatchTests() + { + UseRealFor(); + _lazyTarget = new Lazy(CreateInstance); + } + + [Fact] + public async Task ConcreteDeliveryFiresEveryAssignableHandler() + { + // Arrange + var concreteHits = 0; + var orderInterfaceHits = 0; + var orderEventInterfaceHits = 0; + + var services = new ServiceCollection(); + services.AddScoped(_ => new ConcreteConsumer(() => concreteHits++)); + services.AddScoped(_ => new OrderInterfaceConsumer(() => orderInterfaceHits++)); + services.AddScoped(_ => new OrderEventInterfaceConsumer(() => orderEventInterfaceHits++)); + services.AddSingleton(Mock.Of()); + services.AddSingleton(Mock.Of()); + var serviceProvider = services.BuildServiceProvider(); + + var queue = new QueueDefinition("orders"); + queue.AddSubscription(new Subscription(new MessageType(typeof(OrderPlaced)))); + queue.AddConsumer(new ConsumerRegistration + { + ConsumerType = new ConsumerType(typeof(ConcreteConsumer)), + MessageType = new MessageType(typeof(OrderPlaced)), + }); + queue.AddConsumer(new ConsumerRegistration + { + ConsumerType = new ConsumerType(typeof(OrderInterfaceConsumer)), + MessageType = new MessageType(typeof(IOrder)), + }); + queue.AddConsumer(new ConsumerRegistration + { + ConsumerType = new ConsumerType(typeof(OrderEventInterfaceConsumer)), + MessageType = new MessageType(typeof(IOrderEvent)), + }); + Target.RegisterQueue(queue); + + var plan = Target.GetPlanByFullName(typeof(OrderPlaced).FullName!); + plan.ShouldNotBeNull(); + plan.Handlers.Count.ShouldBe(3); + + var message = new OrderPlaced("abc"); + var ea = CreateDeliverEventArgs(); + + // Act + foreach (var handler in plan.Handlers) + { + await handler.DispatchAsync(serviceProvider, message, ea, (MessageEnvelope?)null, Mock.Of(), CancellationToken.None); + } + + // Assert + concreteHits.ShouldBe(1); + orderInterfaceHits.ShouldBe(1); + orderEventInterfaceHits.ShouldBe(1); + } + + private static RabbitMQ.Client.Events.BasicDeliverEventArgs CreateDeliverEventArgs() + => new( + consumerTag: "tag", + deliveryTag: 1, + redelivered: false, + exchange: "ex", + routingKey: "rk", + properties: new BasicProperties { Headers = new Dictionary() }, + body: ReadOnlyMemory.Empty); +} + +internal interface IOrderEvent { } +internal interface IOrder : IOrderEvent { } +internal sealed record OrderPlaced(string OrderId) : IOrder; + +internal sealed class ConcreteConsumer(Action onConsume) : IConsumer +{ + public Task ConsumeAsync(IMessageContext messageContext, CancellationToken cancellationToken = default) + { + onConsume(); + return Task.CompletedTask; + } +} + +internal sealed class OrderInterfaceConsumer(Action onConsume) : IConsumer +{ + public Task ConsumeAsync(IMessageContext messageContext, CancellationToken cancellationToken = default) + { + onConsume(); + return Task.CompletedTask; + } +} + +internal sealed class OrderEventInterfaceConsumer(Action onConsume) : IConsumer +{ + public Task ConsumeAsync(IMessageContext messageContext, CancellationToken cancellationToken = default) + { + onConsume(); + return Task.CompletedTask; + } +} diff --git a/tests/Vulthil.Messaging.Tests/ConsumerRegistrationTests.cs b/tests/Vulthil.Messaging.Tests/ConsumerRegistrationTests.cs index 9f69fe0..1bcfadc 100644 --- a/tests/Vulthil.Messaging.Tests/ConsumerRegistrationTests.cs +++ b/tests/Vulthil.Messaging.Tests/ConsumerRegistrationTests.cs @@ -6,9 +6,6 @@ namespace Vulthil.Messaging.Tests; -/// -/// Represents the ConsumerRegistrationTests. -/// public sealed class ConsumerRegistrationTests : BaseUnitTestCase { private static HostApplicationBuilder CreateHostBuilder() @@ -22,9 +19,6 @@ private static IReadOnlyCollection GetQueueDefinitions(HostAppl return [.. sp.GetRequiredService().QueueDefinitions]; } - /// - /// Executes this member. - /// [Fact] public void AddConsumerShouldRegisterConsumerInServiceCollection() { @@ -45,9 +39,6 @@ public void AddConsumerShouldRegisterConsumerInServiceCollection() consumerServices.Count.ShouldBeGreaterThan(0); } - /// - /// Executes this member. - /// [Fact] public void AddConsumerShouldRegisterConsumerOnlyOnce() { @@ -72,9 +63,6 @@ public void AddConsumerShouldRegisterConsumerOnlyOnce() registrations.Count.ShouldBe(1); } - /// - /// Executes this member. - /// [Fact] public void AddConsumerShouldAddConsumerRegistrationToQueueDefinition() { @@ -100,11 +88,8 @@ public void AddConsumerShouldAddConsumerRegistrationToQueueDefinition() queue.Registrations.First().MessageType.Type.ShouldBe(typeof(TestMessage)); } - /// - /// Executes this member. - /// [Fact] - public void AddConsumerWithRoutingKeyShouldUseCustomRoutingKey() + public void SubscribeWithRoutingKeyShouldRecordOnSubscription() { // Arrange var builder = CreateHostBuilder(); @@ -116,23 +101,19 @@ public void AddConsumerWithRoutingKeyShouldUseCustomRoutingKey() { x.ConfigureQueue(queueName, q => { - q.AddConsumer(c => - { - c.Bind(customRoutingKey); - }); + q.Subscribe(customRoutingKey); + q.AddConsumer(); }); }); // Assert var queue = GetQueueDefinitions(builder).First(); - queue.Registrations.First().RoutingKey.ShouldBe(customRoutingKey); + queue.Subscriptions.ShouldContain(s => + s.MessageType.Type == typeof(TestMessage) && s.RoutingKey == customRoutingKey); } - /// - /// Executes this member. - /// [Fact] - public void AddConsumerWithoutRoutingKeyBindingShouldUseDefaultWildcard() + public void AddConsumerShouldAutoSubscribeWithNullRoutingKey() { // Arrange var builder = CreateHostBuilder(); @@ -149,12 +130,10 @@ public void AddConsumerWithoutRoutingKeyBindingShouldUseDefaultWildcard() // Assert var queue = GetQueueDefinitions(builder).First(); - queue.Registrations.First().RoutingKey.ShouldBe("#"); + queue.Subscriptions.ShouldContain(s => + s.MessageType.Type == typeof(TestMessage) && s.RoutingKey == null); } - /// - /// Executes this member. - /// [Fact] public void AddMultipleConsumersToSameQueueShouldRegisterAll() { @@ -180,9 +159,6 @@ public void AddMultipleConsumersToSameQueueShouldRegisterAll() types.Contains(typeof(AnotherTestConsumer)).ShouldBeTrue(); } - /// - /// Executes this member. - /// [Fact] public void SameConsumerInMultipleQueuesWithDifferentRoutingKeysShouldRegisterBoth() { @@ -194,17 +170,13 @@ public void SameConsumerInMultipleQueuesWithDifferentRoutingKeysShouldRegisterBo { x.ConfigureQueue("Queue1", q => { - q.AddConsumer(c => - { - c.Bind("route1"); - }); + q.Subscribe("route1"); + q.AddConsumer(); }); x.ConfigureQueue("Queue2", q => { - q.AddConsumer(c => - { - c.Bind("route2"); - }); + q.Subscribe("route2"); + q.AddConsumer(); }); }); @@ -213,25 +185,19 @@ public void SameConsumerInMultipleQueuesWithDifferentRoutingKeysShouldRegisterBo queues.Count.ShouldBe(2); var queue1 = queues.First(q => q.Name == "Queue1"); - queue1.Registrations.First().RoutingKey.ShouldBe("route1"); + queue1.Subscriptions.ShouldContain(s => s.RoutingKey == "route1"); var queue2 = queues.First(q => q.Name == "Queue2"); - queue2.Registrations.First().RoutingKey.ShouldBe("route2"); + queue2.Subscriptions.ShouldContain(s => s.RoutingKey == "route2"); } private class TestMessage { - /// - /// Gets or sets this member value. - /// public string Content { get; set; } = string.Empty; } private class TestMessageConsumer : IConsumer { - /// - /// Executes this member. - /// public Task ConsumeAsync(IMessageContext messageContext, CancellationToken cancellationToken = default) { return Task.CompletedTask; @@ -240,17 +206,11 @@ public Task ConsumeAsync(IMessageContext messageContext, Cancellati private class AnotherMessage { - /// - /// Gets or sets this member value. - /// public string Data { get; set; } = string.Empty; } private class AnotherTestConsumer : IConsumer { - /// - /// Executes this member. - /// public Task ConsumeAsync(IMessageContext messageContext, CancellationToken cancellationToken = default) { return Task.CompletedTask; diff --git a/tests/Vulthil.Messaging.Tests/MessageConfigurationUrnTests.cs b/tests/Vulthil.Messaging.Tests/MessageConfigurationUrnTests.cs new file mode 100644 index 0000000..a14b5a9 --- /dev/null +++ b/tests/Vulthil.Messaging.Tests/MessageConfigurationUrnTests.cs @@ -0,0 +1,41 @@ +using Vulthil.xUnit; + +namespace Vulthil.Messaging.Tests; + +public sealed class MessageConfigurationUrnTests : BaseUnitTestCase +{ + [Fact] + public void DefaultUrnUsesColonSeparatedNamespace() + { + // Act + var config = new MessageConfiguration(); + + // Assert + config.Urn.AbsoluteUri.ShouldBe($"urn:message:{typeof(NamespacedMessage).Namespace}:{nameof(NamespacedMessage)}"); + } + + [Fact] + public void DefaultUrnHandlesGlobalNamespace() + { + // Arrange + var config = new MessageConfiguration("Globally"); + + // Assert + config.Urn.AbsoluteUri.ShouldBe("urn:message:Globally"); + } + + [Fact] + public void ExplicitUrnOverridesDefault() + { + // Arrange + var config = new MessageConfiguration + { + Urn = new Uri("urn:message:Acme.Orders:OrderPlaced") + }; + + // Assert + config.Urn.AbsoluteUri.ShouldBe("urn:message:Acme.Orders:OrderPlaced"); + } +} + +internal sealed record NamespacedMessage(string Content); diff --git a/tests/Vulthil.Messaging.Tests/QueueConfiguratorBuildTests.cs b/tests/Vulthil.Messaging.Tests/QueueConfiguratorBuildTests.cs new file mode 100644 index 0000000..70f6798 --- /dev/null +++ b/tests/Vulthil.Messaging.Tests/QueueConfiguratorBuildTests.cs @@ -0,0 +1,141 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Vulthil.Messaging.Abstractions.Consumers; +using Vulthil.Messaging.Queues; +using Vulthil.xUnit; + +namespace Vulthil.Messaging.Tests; + +public sealed class QueueConfiguratorBuildTests : BaseUnitTestCase +{ + protected override HostApplicationBuilder CreateInstance() => Host.CreateApplicationBuilder(); + + private static QueueDefinition? GetQueue(HostApplicationBuilder builder, string queueName) + { + using var sp = builder.Services.BuildServiceProvider(); + return sp.GetRequiredService() + .QueueDefinitions.FirstOrDefault(q => string.Equals(q.Name, queueName, StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void BuildAutoSubscribesConcreteConsumerMessageTypes() + { + // Act + Target.AddMessaging(m => m.ConfigureQueue("orders", q => q.AddConsumer())); + + // Assert + var queue = GetQueue(Target, "orders"); + queue.ShouldNotBeNull(); + queue.Subscriptions.ShouldContain(s => s.MessageType.Type == typeof(OrderPlaced)); + } + + [Fact] + public void BuildThrowsWhenPolymorphicConsumerHasNoMatchingSubscription() + { + // Act & Assert + var ex = Should.Throw(() => + Target.AddMessaging(m => m.ConfigureQueue("orders", q => q.AddConsumer()))); + + ex.Message.ShouldContain("no concrete subscribed type"); + ex.Message.ShouldContain(nameof(PolymorphicOrderConsumer)); + } + + [Fact] + public void BuildThrowsWhenSubscriptionHasNoMatchingConsumer() + { + // Act & Assert + var ex = Should.Throw(() => + Target.AddMessaging(m => m.ConfigureQueue("orders", q => q.Subscribe()))); + + ex.Message.ShouldContain("has no matching consumer"); + ex.Message.ShouldContain(typeof(OrderPlaced).FullName!); + } + + [Fact] + public void BuildAcceptsPolymorphicConsumerWithExplicitConcreteSubscription() + { + // Act + Target.AddMessaging(m => m.ConfigureQueue("orders", q => + { + q.Subscribe(); + q.AddConsumer(); + })); + + // Assert + var queue = GetQueue(Target, "orders"); + queue.ShouldNotBeNull(); + queue.Subscriptions.ShouldContain(s => s.MessageType.Type == typeof(OrderPlaced)); + queue.Registrations.ShouldContain(r => r.ConsumerType.Type == typeof(PolymorphicOrderConsumer)); + } + + [Fact] + public void SubscribeRejectsAbstractType() + { + var ex = Should.Throw(() => + Target.AddMessaging(m => m.ConfigureQueue("orders", q => q.Subscribe()))); + + ex.Message.ShouldContain("abstract or interface"); + } + + [Fact] + public void SubscribeRejectsInterfaceType() + { + var ex = Should.Throw(() => + Target.AddMessaging(m => m.ConfigureQueue("orders", q => q.Subscribe()))); + + ex.Message.ShouldContain("abstract or interface"); + } + + [Fact] + public void SubscribeAllDiscoversConcreteImplementersAndSkipsAbstractAndInterface() + { + // Act + Target.AddMessaging(m => m.ConfigureQueue("orders", q => + { + q.SubscribeAll(typeof(IOrderEvent).Assembly); + q.AddConsumer(); + })); + + // Assert + var queue = GetQueue(Target, "orders"); + queue.ShouldNotBeNull(); + queue.Subscriptions.ShouldContain(s => s.MessageType.Type == typeof(OrderPlaced)); + queue.Subscriptions.ShouldContain(s => s.MessageType.Type == typeof(OrderCancelled)); + queue.Subscriptions.ShouldNotContain(s => s.MessageType.Type == typeof(IOrderEvent)); + queue.Subscriptions.ShouldNotContain(s => s.MessageType.Type == typeof(IOrder)); + queue.Subscriptions.ShouldNotContain(s => s.MessageType.Type == typeof(AbstractOrderEventBase)); + } + + [Fact] + public void BuildThrowsWhenRequestConsumerTargetsPolymorphicType() + { + var ex = Should.Throw(() => + Target.AddMessaging(m => m.ConfigureQueue("orders", q => q.AddRequestConsumer()))); + + ex.Message.ShouldContain("polymorphic request type"); + } +} + +internal interface IOrderEvent { } +internal interface IOrder : IOrderEvent { } +internal abstract record AbstractOrderEventBase : IOrderEvent; +internal sealed record OrderPlaced(string OrderId) : IOrder; +internal sealed record OrderCancelled(string OrderId) : IOrder; + +internal sealed class OrderPlacedConsumer : IConsumer +{ + public Task ConsumeAsync(IMessageContext messageContext, CancellationToken cancellationToken = default) + => Task.CompletedTask; +} + +internal sealed class PolymorphicOrderConsumer : IConsumer +{ + public Task ConsumeAsync(IMessageContext messageContext, CancellationToken cancellationToken = default) + => Task.CompletedTask; +} + +internal sealed class PolymorphicRequestConsumer : IRequestConsumer +{ + public Task ConsumeAsync(IMessageContext messageContext, CancellationToken cancellationToken = default) + => Task.FromResult(new OrderPlaced("x")); +} diff --git a/tests/Vulthil.Messaging.Tests/QueueDefinitionTests.cs b/tests/Vulthil.Messaging.Tests/QueueDefinitionTests.cs index 7568633..df8125f 100644 --- a/tests/Vulthil.Messaging.Tests/QueueDefinitionTests.cs +++ b/tests/Vulthil.Messaging.Tests/QueueDefinitionTests.cs @@ -4,14 +4,8 @@ namespace Vulthil.Messaging.Tests; -/// -/// Represents the QueueDefinitionTests. -/// public sealed class QueueDefinitionTests : BaseUnitTestCase { - /// - /// Executes this member. - /// [Fact] public void MessageTypeShouldReturnFullName() { @@ -25,9 +19,6 @@ public void MessageTypeShouldReturnFullName() name.ShouldBe(typeof(string).FullName); } - /// - /// Executes this member. - /// [Fact] public void ConsumerTypeShouldReturnFullName() { @@ -41,9 +32,6 @@ public void ConsumerTypeShouldReturnFullName() name.ShouldContain(nameof(TestConsumer)); } - /// - /// Executes this member. - /// [Fact] public void QueueDefinitionShouldHaveCorrectDefaults() { @@ -64,9 +52,6 @@ public void QueueDefinitionShouldHaveCorrectDefaults() queue.Registrations.Count.ShouldBe(0); } - /// - /// Executes this member. - /// [Fact] public void QueueDefinitionShouldAllowModifyingProperties() { @@ -102,9 +87,6 @@ public void QueueDefinitionShouldAllowModifyingProperties() queue.ExchangeAutoDelete.ShouldBeTrue(); } - /// - /// Executes this member. - /// [Fact] public void QueueDefinitionShouldTrackExchangeArguments() { @@ -123,9 +105,6 @@ public void QueueDefinitionShouldTrackExchangeArguments() queue.ExchangeArguments["key2"].ShouldBe(42); } - /// - /// Executes this member. - /// [Fact] public void AddConsumerShouldRegisterInQueue() { @@ -137,7 +116,6 @@ public void AddConsumerShouldRegisterInQueue() { ConsumerType = new ConsumerType(typeof(TestConsumer)), MessageType = new MessageType(typeof(TestMessage)), - RoutingKey = "test.key" }; // Act @@ -148,9 +126,6 @@ public void AddConsumerShouldRegisterInQueue() queue.Registrations.First().ShouldBe(registration); } - /// - /// Executes this member. - /// [Fact] public void RequestConsumerRegistrationShouldHaveResponseType() { @@ -166,26 +141,6 @@ public void RequestConsumerRegistrationShouldHaveResponseType() registration.ResponseType.ShouldBe(typeof(string)); } - /// - /// Executes this member. - /// - [Fact] - public void ConsumerRegistrationDefaultRoutingKeyShouldBeWildcard() - { - // Arrange & Act - var registration = new ConsumerRegistration - { - ConsumerType = new ConsumerType(typeof(TestConsumer)), - MessageType = new MessageType(typeof(TestMessage)) - }; - - // Assert - registration.RoutingKey.ShouldBe("#"); - } - - /// - /// Executes this member. - /// [Fact] public void RegistrationsShouldBeReadOnly() { @@ -209,9 +164,6 @@ public void RegistrationsShouldBeReadOnly() private class TestMessage { } private class TestConsumer : IConsumer { - /// - /// Executes this member. - /// public Task ConsumeAsync(IMessageContext messageContext, CancellationToken cancellationToken = default) => Task.CompletedTask; } } From eae2c1485fa5367c6a193e1959a0cf2b9c576977 Mon Sep 17 00:00:00 2001 From: Vulthil Date: Sat, 30 May 2026 21:04:35 +0200 Subject: [PATCH 10/42] feat(messaging): support per-request timeout via IRequestContext --- docs/articles/messaging.md | 25 ++++++++++ .../vulthil-messaging-abstractions.md | 13 +++++ .../PublicAPI.Unshipped.txt | 4 +- .../Publishers/IPublishContext.cs | 49 +++++++++++++++++++ .../Publishers/IPublisher.cs | 48 ------------------ .../Publishers/IRequestContext.cs | 13 +++++ .../Publishers/IRequester.cs | 2 +- .../Requests/PublishContext.cs | 40 +-------------- .../Requests/RabbitMqRequester.cs | 30 ++++++------ .../Requests/RequestContext.cs | 14 ++++++ 10 files changed, 134 insertions(+), 104 deletions(-) create mode 100644 src/Vulthil.Messaging.Abstractions/Publishers/IPublishContext.cs create mode 100644 src/Vulthil.Messaging.Abstractions/Publishers/IRequestContext.cs create mode 100644 src/Vulthil.Messaging.RabbitMq/Requests/RequestContext.cs diff --git a/docs/articles/messaging.md b/docs/articles/messaging.md index 0cee11f..d418877 100644 --- a/docs/articles/messaging.md +++ b/docs/articles/messaging.md @@ -593,6 +593,31 @@ public sealed class OrderLookupService(IRequester requester) The reply queue is created lazily on the first request, so producer-only services that never call `RequestAsync` do not declare any reply infrastructure. +### Configuring the request + +`RequestAsync` accepts an optional `Func` to configure the +outgoing request. `IRequestContext` extends `IPublishContext` — so the routing key, +correlation id, and headers can be set just like a publish — and adds +`SetTimeout(TimeSpan)` for overriding the response timeout on a per-request basis: + +```csharp +var result = await requester.RequestAsync( + new GetOrderRequest(orderId), + ctx => + { + ctx.SetTimeout(TimeSpan.FromSeconds(5)); + ctx.AddHeader("priority", "high"); + return Task.CompletedTask; + }, + cancellationToken: ct); +``` + +When no timeout is set on the context, the request falls back to +`Messaging:Options:DefaultTimeout` (see +[Configuration-driven Setup](#configuration-driven-setup)). A request that exceeds +its timeout completes with a `Result` failure carrying the +`Messaging.Request.Timeout` error code rather than throwing. + ## Testing Messaging `Vulthil.Messaging.TestHarness` provides an in-memory transport that captures diff --git a/docs/articles/packages/vulthil-messaging-abstractions.md b/docs/articles/packages/vulthil-messaging-abstractions.md index 2833cb4..8b002df 100644 --- a/docs/articles/packages/vulthil-messaging-abstractions.md +++ b/docs/articles/packages/vulthil-messaging-abstractions.md @@ -59,6 +59,19 @@ Result result = await requester.RequestAsync` to configure the request. The +context exposes the `IPublishContext` members (routing key, correlation id, +headers) plus `SetTimeout` for overriding the default response timeout per request: + +```csharp +Result result = await requester.RequestAsync( + new GetOrderRequest(orderId), + ctx => { ctx.SetTimeout(TimeSpan.FromSeconds(5)); return Task.CompletedTask; }, + cancellationToken: ct); +``` + +See [Messaging — Request/Reply](../messaging.md#configuring-the-request) for details. + ### Publishing from a consumer `IMessageContext` exposes `PublishAsync` with automatic correlation propagation diff --git a/src/Vulthil.Messaging.Abstractions/PublicAPI.Unshipped.txt b/src/Vulthil.Messaging.Abstractions/PublicAPI.Unshipped.txt index a61d297..8ce51eb 100644 --- a/src/Vulthil.Messaging.Abstractions/PublicAPI.Unshipped.txt +++ b/src/Vulthil.Messaging.Abstractions/PublicAPI.Unshipped.txt @@ -60,8 +60,10 @@ Vulthil.Messaging.Abstractions.Publishers.IPublishContext.SetRoutingKey(string! Vulthil.Messaging.Abstractions.Publishers.IPublisher Vulthil.Messaging.Abstractions.Publishers.IPublisher.PublishAsync(TMessage message, System.Func? configureContext = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! Vulthil.Messaging.Abstractions.Publishers.IPublisher.PublishAsync(TMessage message, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +Vulthil.Messaging.Abstractions.Publishers.IRequestContext +Vulthil.Messaging.Abstractions.Publishers.IRequestContext.SetTimeout(System.TimeSpan timeout) -> Vulthil.Messaging.Abstractions.Publishers.IRequestContext! Vulthil.Messaging.Abstractions.Publishers.IRequester -Vulthil.Messaging.Abstractions.Publishers.IRequester.RequestAsync(TRequest message, System.Func? configureContext = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +Vulthil.Messaging.Abstractions.Publishers.IRequester.RequestAsync(TRequest message, System.Func? configureContext = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! Vulthil.Messaging.Abstractions.Publishers.IRequester.RequestAsync(TRequest message, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task!>! Vulthil.Messaging.Abstractions.Publishers.ISendEndpoint Vulthil.Messaging.Abstractions.Publishers.ISendEndpoint.Address.get -> System.Uri! diff --git a/src/Vulthil.Messaging.Abstractions/Publishers/IPublishContext.cs b/src/Vulthil.Messaging.Abstractions/Publishers/IPublishContext.cs new file mode 100644 index 0000000..36ec7c0 --- /dev/null +++ b/src/Vulthil.Messaging.Abstractions/Publishers/IPublishContext.cs @@ -0,0 +1,49 @@ +namespace Vulthil.Messaging.Abstractions.Publishers; + +/// +/// Provides a mutable context for configuring outgoing message properties. +/// +public interface IPublishContext +{ + /// + /// Sets the routing key for the published message. + /// + /// The routing key. + void SetRoutingKey(string routingKey); + /// + /// Sets the correlation identifier for the published message. + /// + /// The correlation identifier. + void SetCorrelationId(string correlationId); + /// + /// Adds a custom header to the published message. + /// + /// The header key. + /// The header value. + void AddHeader(string key, object? value); + /// + /// Adds multiple custom headers to the published message. + /// + /// The headers to add. + void AddHeaders(IDictionary headers); + /// + /// Gets or sets the unique message identifier assigned to the outgoing message. + /// + string? MessageId { get; set; } + /// + /// Gets or sets the conversation identifier that groups related messages across services. + /// + string? ConversationId { get; set; } + /// + /// Gets or sets the identifier of the message that initiated this chain. + /// + string? InitiatorId { get; set; } + /// + /// Gets or sets the address where replies to this message should be sent. + /// + Uri? ResponseAddress { get; set; } + /// + /// Gets or sets the address where fault notifications for this message should be sent. + /// + Uri? FaultAddress { get; set; } +} diff --git a/src/Vulthil.Messaging.Abstractions/Publishers/IPublisher.cs b/src/Vulthil.Messaging.Abstractions/Publishers/IPublisher.cs index a3e4cf9..d3e3b17 100644 --- a/src/Vulthil.Messaging.Abstractions/Publishers/IPublisher.cs +++ b/src/Vulthil.Messaging.Abstractions/Publishers/IPublisher.cs @@ -29,51 +29,3 @@ Task PublishAsync( CancellationToken cancellationToken = default) where TMessage : notnull; } - -/// -/// Provides a mutable context for configuring outgoing message properties. -/// -public interface IPublishContext -{ - /// - /// Sets the routing key for the published message. - /// - /// The routing key. - void SetRoutingKey(string routingKey); - /// - /// Sets the correlation identifier for the published message. - /// - /// The correlation identifier. - void SetCorrelationId(string correlationId); - /// - /// Adds a custom header to the published message. - /// - /// The header key. - /// The header value. - void AddHeader(string key, object? value); - /// - /// Adds multiple custom headers to the published message. - /// - /// The headers to add. - void AddHeaders(IDictionary headers); - /// - /// Gets or sets the unique message identifier assigned to the outgoing message. - /// - string? MessageId { get; set; } - /// - /// Gets or sets the conversation identifier that groups related messages across services. - /// - string? ConversationId { get; set; } - /// - /// Gets or sets the identifier of the message that initiated this chain. - /// - string? InitiatorId { get; set; } - /// - /// Gets or sets the address where replies to this message should be sent. - /// - Uri? ResponseAddress { get; set; } - /// - /// Gets or sets the address where fault notifications for this message should be sent. - /// - Uri? FaultAddress { get; set; } -} diff --git a/src/Vulthil.Messaging.Abstractions/Publishers/IRequestContext.cs b/src/Vulthil.Messaging.Abstractions/Publishers/IRequestContext.cs new file mode 100644 index 0000000..cc263a8 --- /dev/null +++ b/src/Vulthil.Messaging.Abstractions/Publishers/IRequestContext.cs @@ -0,0 +1,13 @@ +namespace Vulthil.Messaging.Abstractions.Publishers; + +/// +/// Extends with request-specific configuration options, such as timeouts. +/// +public interface IRequestContext : IPublishContext +{ + /// + /// Sets the timeout for the request, after which it should be considered failed if no response is received. + /// + IRequestContext SetTimeout(TimeSpan timeout); + +} diff --git a/src/Vulthil.Messaging.Abstractions/Publishers/IRequester.cs b/src/Vulthil.Messaging.Abstractions/Publishers/IRequester.cs index a03c671..d661664 100644 --- a/src/Vulthil.Messaging.Abstractions/Publishers/IRequester.cs +++ b/src/Vulthil.Messaging.Abstractions/Publishers/IRequester.cs @@ -32,7 +32,7 @@ Task> RequestAsync( /// A containing the response on success or an error on failure. Task> RequestAsync( TRequest message, - Func? configureContext = null, + Func? configureContext = null, CancellationToken cancellationToken = default) where TRequest : notnull where TResponse : notnull; diff --git a/src/Vulthil.Messaging.RabbitMq/Requests/PublishContext.cs b/src/Vulthil.Messaging.RabbitMq/Requests/PublishContext.cs index 433b702..b5fb20b 100644 --- a/src/Vulthil.Messaging.RabbitMq/Requests/PublishContext.cs +++ b/src/Vulthil.Messaging.RabbitMq/Requests/PublishContext.cs @@ -2,47 +2,20 @@ namespace Vulthil.Messaging.RabbitMq.Requests; -internal sealed class PublishContext : IPublishContext +internal class PublishContext : IPublishContext { internal Dictionary Headers { get; } = []; internal string? RoutingKey { get; private set; } internal string? CorrelationId { get; private set; } - /// - /// Gets or sets this member value. - /// public string? MessageId { get; set; } - /// - /// Executes this member. - /// public string? ConversationId { get => Headers.TryGetValue("ConversationId", out var value) && value is string conversationId ? conversationId : null; set => Headers["ConversationId"] = value; } - /// - /// Executes this member. - /// public string? InitiatorId { get => Headers.TryGetValue("InitiatorId", out var value) && value is string initiatorId ? initiatorId : null; set => Headers["InitiatorId"] = value; } - /// - /// Executes this member. - /// public Uri? SourceAddress { get => Headers.TryGetValue("SourceAddress", out var value) && value is string sourceAddress ? new Uri(sourceAddress) : null; set => Headers["SourceAddress"] = MapUriToString(value); } - /// - /// Executes this member. - /// public Uri? DestinationAddress { get => Headers.TryGetValue("DestinationAddress", out var value) && value is string destinationAddress ? new Uri(destinationAddress) : null; set => Headers["DestinationAddress"] = MapUriToString(value); } - /// - /// Executes this member. - /// public Uri? ResponseAddress { get => Headers.TryGetValue("ResponseAddress", out var value) && value is string responseAddress ? new Uri(responseAddress) : null; set => Headers["ResponseAddress"] = MapUriToString(value); } - /// - /// Executes this member. - /// public Uri? FaultAddress { get => Headers.TryGetValue("FaultAddress", out var value) && value is string faultAddress ? new Uri(faultAddress) : null; set => Headers["FaultAddress"] = MapUriToString(value); } - /// - /// Executes this member. - /// public void AddHeader(string key, object? value) => Headers[key] = value; - /// - /// Executes this member. - /// public void AddHeaders(IDictionary headers) { foreach (var item in headers) @@ -50,15 +23,8 @@ public void AddHeaders(IDictionary headers) Headers[item.Key] = item.Value; } } - /// - /// Executes this member. - /// public void SetRoutingKey(string routingKey) => RoutingKey = routingKey; - /// - /// Executes this member. - /// public void SetCorrelationId(string correlationId) => CorrelationId = correlationId; - private static string? MapUriToString(Uri? uri) { if (uri is null) @@ -68,10 +34,6 @@ public void AddHeaders(IDictionary headers) return uri.Scheme == "queue" ? uri.LocalPath.TrimStart('/') : uri.ToString(); } - - /// - /// Executes this member. - /// public static string? ResolveRoutingKeyFromUri(Uri? uri) { if (uri == null) diff --git a/src/Vulthil.Messaging.RabbitMq/Requests/RabbitMqRequester.cs b/src/Vulthil.Messaging.RabbitMq/Requests/RabbitMqRequester.cs index ac6287f..5a4468a 100644 --- a/src/Vulthil.Messaging.RabbitMq/Requests/RabbitMqRequester.cs +++ b/src/Vulthil.Messaging.RabbitMq/Requests/RabbitMqRequester.cs @@ -32,7 +32,6 @@ public RabbitMqRequester( } private JsonSerializerOptions JsonOptions => _messageConfigurationProvider.JsonSerializerOptions; - private TimeSpan DefaultTimeout => _messageConfigurationProvider.DefaultTimeout; public Task> RequestAsync( TRequest message, @@ -42,39 +41,40 @@ public Task> RequestAsync( public async Task> RequestAsync( TRequest message, - Func? configureContext = null, + Func? configureContext = null, CancellationToken cancellationToken = default) where TRequest : notnull where TResponse : notnull { ArgumentNullException.ThrowIfNull(message); - var publishContext = new PublishContext(); + var requestContext = new RequestContext(); configureContext ??= (_ => Task.CompletedTask); - await configureContext(publishContext); + await configureContext(requestContext); var tcs = new TaskCompletionSource>(TaskCreationOptions.RunContinuationsAsynchronously); - using var timeoutCts = new CancellationTokenSource(DefaultTimeout); + var timeout = requestContext.Timeout ?? _messageConfigurationProvider.DefaultTimeout; + using var timeoutCts = new CancellationTokenSource(timeout); using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token); var type = message.GetType(); var messageConfiguration = _messageConfigurationProvider.GetMessageConfiguration(type); - var routingKey = publishContext.RoutingKey + var routingKey = requestContext.RoutingKey ?? messageConfiguration.RoutingKeyFormatter?.Invoke(message) ?? string.Empty; - var correlationId = publishContext.CorrelationId + var correlationId = requestContext.CorrelationId ?? messageConfiguration.CorrelationIdFormatter?.Invoke(message) ?? Guid.CreateVersion7().ToString(); - var messageId = publishContext.MessageId ?? Guid.CreateVersion7().ToString(); + var messageId = requestContext.MessageId ?? Guid.CreateVersion7().ToString(); var exchange = messageConfiguration.Exchange; var urn = messageConfiguration.Urn; var urnString = urn.AbsoluteUri; var replyQueue = await _listener.GetReplyToQueueNameAsync(cancellationToken); - var replyTo = PublishContext.ResolveRoutingKeyFromUri(publishContext.ResponseAddress) ?? replyQueue; + var replyTo = PublishContext.ResolveRoutingKeyFromUri(requestContext.ResponseAddress) ?? replyQueue; using var activity = MessagingInstrumentation.ActivitySource.StartActivity( $"{exchange} request", @@ -92,7 +92,7 @@ public async Task> RequestAsync( } _listener.RegisterWaiter(correlationId, tcs); - MessagingLog.RequestSending(_logger, urnString, correlationId, DefaultTimeout.TotalSeconds); + MessagingLog.RequestSending(_logger, urnString, correlationId, timeout.TotalSeconds); try { @@ -103,12 +103,12 @@ public async Task> RequestAsync( ContentType = RabbitMqConstants.ContentType, Type = urnString, Timestamp = new AmqpTimestamp(DateTimeOffset.UtcNow.ToUnixTimeSeconds()), - Expiration = DefaultTimeout.TotalMilliseconds.ToString("F0", CultureInfo.InvariantCulture), - Headers = publishContext.Headers, + Expiration = timeout.TotalMilliseconds.ToString("F0", CultureInfo.InvariantCulture), + Headers = requestContext.Headers, MessageId = messageId, }; - var envelope = MessageEnvelopeFactory.Create(message, publishContext, messageId, correlationId, urn, JsonOptions); + var envelope = MessageEnvelopeFactory.Create(message, requestContext, messageId, correlationId, urn, JsonOptions); var body = JsonSerializer.SerializeToUtf8Bytes(envelope, JsonOptions); await _publisher.InternalPublishAsync(body, props, routingKey, messageConfiguration, cancellationToken); @@ -117,8 +117,8 @@ public async Task> RequestAsync( { if (timeoutCts.IsCancellationRequested) { - MessagingLog.RequestTimedOut(_logger, urnString, correlationId, DefaultTimeout.TotalSeconds); - tcs.TrySetResult(Result.Failure(Error.Failure("Messaging.Request.Timeout", $"Request timed out after {DefaultTimeout.TotalSeconds}s"))); + MessagingLog.RequestTimedOut(_logger, urnString, correlationId, timeout.TotalSeconds); + tcs.TrySetResult(Result.Failure(Error.Failure("Messaging.Request.Timeout", $"Request timed out after {timeout.TotalSeconds}s"))); } else { diff --git a/src/Vulthil.Messaging.RabbitMq/Requests/RequestContext.cs b/src/Vulthil.Messaging.RabbitMq/Requests/RequestContext.cs new file mode 100644 index 0000000..05681fa --- /dev/null +++ b/src/Vulthil.Messaging.RabbitMq/Requests/RequestContext.cs @@ -0,0 +1,14 @@ +using Vulthil.Messaging.Abstractions.Publishers; + +namespace Vulthil.Messaging.RabbitMq.Requests; + +internal sealed class RequestContext : PublishContext, IRequestContext +{ + internal TimeSpan? Timeout { get; private set; } + + public IRequestContext SetTimeout(TimeSpan timeout) + { + Timeout = timeout; + return this; + } +} From ae5f51dc943235f18adfd7c1eff244c2e38f3b8d Mon Sep 17 00:00:00 2001 From: Vulthil Date: Sat, 30 May 2026 21:04:49 +0200 Subject: [PATCH 11/42] test(messaging): expand integration scenarios and add failure-config + timeout tests --- .../RecordWeatherCommandConsumer.cs | 14 +- .../Commands/WeatherAuditConsumer.cs | 21 +++ .../Events/InventoryEventConsumer.cs | 21 +++ .../Events/StockChangedEventConsumer.cs | 21 +++ .../WeatherUpdatedEventConsumer.cs | 5 +- .../Failures/FailingRequestConsumer.cs | 19 ++ .../Failures/FlakyCommandConsumer.cs | 29 +++ .../Failures/PoisonCommandConsumer.cs | 23 +++ .../Infrastructure/AuditConsumeFilter.cs | 13 ++ .../Infrastructure/ReceivedMessageTracker.cs | 19 ++ .../Program.cs | 64 ++++++- .../ReceivedMessageTracker.cs | 18 -- .../GetWeatherRequestConsumer.cs | 5 +- .../FailingRequest.cs | 3 + .../FailingResponse.cs | 3 + .../FlakyCommand.cs | 3 + .../IInventoryEvent.cs | 6 + .../PoisonCommand.cs | 3 + .../StockChangedEvent.cs | 3 + .../UnansweredRequest.cs | 3 + .../UnansweredResponse.cs | 3 + .../WeatherAuditEntry.cs | 3 + .../Program.cs | 55 ++++++ .../AppHostFixture.cs | 3 + .../MessagingConfigurationTests.cs | 173 ++++++++++++++++++ ...hil.Messaging.IntegrationTest.Tests.csproj | 1 + .../RabbitMqRequesterTests.cs | 72 ++++++++ .../Filters/DefaultFilterRegistrationTests.cs | 43 +++++ .../MessagingConfiguratiorTests.cs | 21 +++ .../RetryPolicyConfiguratorTests.cs | 35 ++++ 30 files changed, 671 insertions(+), 34 deletions(-) rename tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ConsumerService/{ => Commands}/RecordWeatherCommandConsumer.cs (52%) create mode 100644 tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ConsumerService/Commands/WeatherAuditConsumer.cs create mode 100644 tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ConsumerService/Events/InventoryEventConsumer.cs create mode 100644 tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ConsumerService/Events/StockChangedEventConsumer.cs rename tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ConsumerService/{ => Events}/WeatherUpdatedEventConsumer.cs (80%) create mode 100644 tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ConsumerService/Failures/FailingRequestConsumer.cs create mode 100644 tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ConsumerService/Failures/FlakyCommandConsumer.cs create mode 100644 tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ConsumerService/Failures/PoisonCommandConsumer.cs create mode 100644 tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ConsumerService/Infrastructure/AuditConsumeFilter.cs create mode 100644 tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ConsumerService/Infrastructure/ReceivedMessageTracker.cs delete mode 100644 tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ConsumerService/ReceivedMessageTracker.cs rename tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ConsumerService/{ => Requests}/GetWeatherRequestConsumer.cs (83%) create mode 100644 tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.Contracts/FailingRequest.cs create mode 100644 tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.Contracts/FailingResponse.cs create mode 100644 tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.Contracts/FlakyCommand.cs create mode 100644 tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.Contracts/IInventoryEvent.cs create mode 100644 tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.Contracts/PoisonCommand.cs create mode 100644 tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.Contracts/StockChangedEvent.cs create mode 100644 tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.Contracts/UnansweredRequest.cs create mode 100644 tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.Contracts/UnansweredResponse.cs create mode 100644 tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.Contracts/WeatherAuditEntry.cs create mode 100644 tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqRequesterTests.cs create mode 100644 tests/Vulthil.Messaging.Tests/RetryPolicyConfiguratorTests.cs diff --git a/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ConsumerService/RecordWeatherCommandConsumer.cs b/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ConsumerService/Commands/RecordWeatherCommandConsumer.cs similarity index 52% rename from tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ConsumerService/RecordWeatherCommandConsumer.cs rename to tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ConsumerService/Commands/RecordWeatherCommandConsumer.cs index 24d6090..fc855e7 100644 --- a/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ConsumerService/RecordWeatherCommandConsumer.cs +++ b/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ConsumerService/Commands/RecordWeatherCommandConsumer.cs @@ -1,18 +1,24 @@ using Microsoft.Extensions.Logging; using Vulthil.Messaging.Abstractions.Consumers; +using Vulthil.Messaging.IntegrationTest.ConsumerService.Infrastructure; using Vulthil.Messaging.IntegrationTest.Contracts; -namespace Vulthil.Messaging.IntegrationTest.ConsumerService; +namespace Vulthil.Messaging.IntegrationTest.ConsumerService.Commands; public sealed partial class RecordWeatherCommandConsumer( ILogger logger, ReceivedMessageTracker tracker) : IConsumer { - public Task ConsumeAsync(IMessageContext messageContext, CancellationToken cancellationToken = default) + private static readonly Uri AuditQueue = new("queue:weather-audit"); + + public async Task ConsumeAsync(IMessageContext messageContext, CancellationToken cancellationToken = default) { LogReceived(logger, messageContext.Message.Id, messageContext.Message.Location); - tracker.RecordCommand(messageContext.Message); - return Task.CompletedTask; + tracker.Record("commands", messageContext.Message); + + await messageContext.SendAsync( + AuditQueue, + new WeatherAuditEntry(messageContext.Message.Id, messageContext.Message.Location)); } [LoggerMessage(EventId = 1, Level = LogLevel.Information, Message = "Received RecordWeatherCommand {Id} for {Location}")] diff --git a/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ConsumerService/Commands/WeatherAuditConsumer.cs b/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ConsumerService/Commands/WeatherAuditConsumer.cs new file mode 100644 index 0000000..83429a5 --- /dev/null +++ b/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ConsumerService/Commands/WeatherAuditConsumer.cs @@ -0,0 +1,21 @@ +using Microsoft.Extensions.Logging; +using Vulthil.Messaging.Abstractions.Consumers; +using Vulthil.Messaging.IntegrationTest.ConsumerService.Infrastructure; +using Vulthil.Messaging.IntegrationTest.Contracts; + +namespace Vulthil.Messaging.IntegrationTest.ConsumerService.Commands; + +public sealed partial class WeatherAuditConsumer( + ILogger logger, + ReceivedMessageTracker tracker) : IConsumer +{ + public Task ConsumeAsync(IMessageContext messageContext, CancellationToken cancellationToken = default) + { + LogReceived(logger, messageContext.Message.SourceId, messageContext.Message.Location); + tracker.Record("audit", messageContext.Message); + return Task.CompletedTask; + } + + [LoggerMessage(EventId = 1, Level = LogLevel.Information, Message = "Received WeatherAuditEntry for source {SourceId} at {Location}")] + private static partial void LogReceived(ILogger logger, Guid sourceId, string location); +} diff --git a/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ConsumerService/Events/InventoryEventConsumer.cs b/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ConsumerService/Events/InventoryEventConsumer.cs new file mode 100644 index 0000000..226f61c --- /dev/null +++ b/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ConsumerService/Events/InventoryEventConsumer.cs @@ -0,0 +1,21 @@ +using Microsoft.Extensions.Logging; +using Vulthil.Messaging.Abstractions.Consumers; +using Vulthil.Messaging.IntegrationTest.ConsumerService.Infrastructure; +using Vulthil.Messaging.IntegrationTest.Contracts; + +namespace Vulthil.Messaging.IntegrationTest.ConsumerService.Events; + +public sealed partial class InventoryEventConsumer( + ILogger logger, + ReceivedMessageTracker tracker) : IConsumer +{ + public Task ConsumeAsync(IMessageContext messageContext, CancellationToken cancellationToken = default) + { + LogReceived(logger, messageContext.Message.GetType().Name, messageContext.Message.Sku); + tracker.Record("inventory.any", messageContext.Message); + return Task.CompletedTask; + } + + [LoggerMessage(EventId = 1, Level = LogLevel.Information, Message = "InventoryEventConsumer received {EventType} for {Sku}")] + private static partial void LogReceived(ILogger logger, string eventType, string sku); +} diff --git a/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ConsumerService/Events/StockChangedEventConsumer.cs b/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ConsumerService/Events/StockChangedEventConsumer.cs new file mode 100644 index 0000000..f79db9d --- /dev/null +++ b/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ConsumerService/Events/StockChangedEventConsumer.cs @@ -0,0 +1,21 @@ +using Microsoft.Extensions.Logging; +using Vulthil.Messaging.Abstractions.Consumers; +using Vulthil.Messaging.IntegrationTest.ConsumerService.Infrastructure; +using Vulthil.Messaging.IntegrationTest.Contracts; + +namespace Vulthil.Messaging.IntegrationTest.ConsumerService.Events; + +public sealed partial class StockChangedEventConsumer( + ILogger logger, + ReceivedMessageTracker tracker) : IConsumer +{ + public Task ConsumeAsync(IMessageContext messageContext, CancellationToken cancellationToken = default) + { + LogReceived(logger, messageContext.Message.Sku, messageContext.Message.Delta); + tracker.Record("inventory.stock-changed", messageContext.Message); + return Task.CompletedTask; + } + + [LoggerMessage(EventId = 1, Level = LogLevel.Information, Message = "StockChangedEventConsumer received {Sku} delta {Delta}")] + private static partial void LogReceived(ILogger logger, string sku, int delta); +} diff --git a/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ConsumerService/WeatherUpdatedEventConsumer.cs b/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ConsumerService/Events/WeatherUpdatedEventConsumer.cs similarity index 80% rename from tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ConsumerService/WeatherUpdatedEventConsumer.cs rename to tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ConsumerService/Events/WeatherUpdatedEventConsumer.cs index 065c1ec..4841bbf 100644 --- a/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ConsumerService/WeatherUpdatedEventConsumer.cs +++ b/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ConsumerService/Events/WeatherUpdatedEventConsumer.cs @@ -1,8 +1,9 @@ using Microsoft.Extensions.Logging; using Vulthil.Messaging.Abstractions.Consumers; +using Vulthil.Messaging.IntegrationTest.ConsumerService.Infrastructure; using Vulthil.Messaging.IntegrationTest.Contracts; -namespace Vulthil.Messaging.IntegrationTest.ConsumerService; +namespace Vulthil.Messaging.IntegrationTest.ConsumerService.Events; public sealed partial class WeatherUpdatedEventConsumer( ILogger logger, @@ -11,7 +12,7 @@ public sealed partial class WeatherUpdatedEventConsumer( public Task ConsumeAsync(IMessageContext messageContext, CancellationToken cancellationToken = default) { LogReceived(logger, messageContext.Message.Id, messageContext.Message.Location); - tracker.RecordEvent(messageContext.Message); + tracker.Record("events", messageContext.Message); return Task.CompletedTask; } diff --git a/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ConsumerService/Failures/FailingRequestConsumer.cs b/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ConsumerService/Failures/FailingRequestConsumer.cs new file mode 100644 index 0000000..c41bd97 --- /dev/null +++ b/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ConsumerService/Failures/FailingRequestConsumer.cs @@ -0,0 +1,19 @@ +using Microsoft.Extensions.Logging; +using Vulthil.Messaging.Abstractions.Consumers; +using Vulthil.Messaging.IntegrationTest.Contracts; + +namespace Vulthil.Messaging.IntegrationTest.ConsumerService.Failures; + +public sealed partial class FailingRequestConsumer( + ILogger logger) : IRequestConsumer +{ + public Task ConsumeAsync(IMessageContext messageContext, CancellationToken cancellationToken = default) + { + LogReceived(logger, messageContext.Message.Reason); + throw new InvalidOperationException( + $"Intentional request failure: {messageContext.Message.Reason}"); + } + + [LoggerMessage(EventId = 1, Level = LogLevel.Warning, Message = "FailingRequest received ({Reason}) — about to throw")] + private static partial void LogReceived(ILogger logger, string reason); +} diff --git a/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ConsumerService/Failures/FlakyCommandConsumer.cs b/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ConsumerService/Failures/FlakyCommandConsumer.cs new file mode 100644 index 0000000..ab580b8 --- /dev/null +++ b/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ConsumerService/Failures/FlakyCommandConsumer.cs @@ -0,0 +1,29 @@ +using Microsoft.Extensions.Logging; +using Vulthil.Messaging.Abstractions.Consumers; +using Vulthil.Messaging.IntegrationTest.ConsumerService.Infrastructure; +using Vulthil.Messaging.IntegrationTest.Contracts; + +namespace Vulthil.Messaging.IntegrationTest.ConsumerService.Failures; + +public sealed partial class FlakyCommandConsumer( + ILogger logger, + ReceivedMessageTracker tracker) : IConsumer +{ + public Task ConsumeAsync(IMessageContext messageContext, CancellationToken cancellationToken = default) + { + var attempt = tracker.RecordAttempt(messageContext.Message.Id); + LogAttempt(logger, messageContext.Message.Id, attempt, messageContext.RetryCount); + + if (attempt < messageContext.Message.FailUntilAttempt) + { + throw new InvalidOperationException( + $"Flaky failure on attempt {attempt} for {messageContext.Message.Id}."); + } + + tracker.Record("flaky", messageContext.Message); + return Task.CompletedTask; + } + + [LoggerMessage(EventId = 1, Level = LogLevel.Information, Message = "FlakyCommand {Id} attempt {Attempt} (retryCount {RetryCount})")] + private static partial void LogAttempt(ILogger logger, Guid id, int attempt, int retryCount); +} diff --git a/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ConsumerService/Failures/PoisonCommandConsumer.cs b/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ConsumerService/Failures/PoisonCommandConsumer.cs new file mode 100644 index 0000000..318aff2 --- /dev/null +++ b/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ConsumerService/Failures/PoisonCommandConsumer.cs @@ -0,0 +1,23 @@ +using Microsoft.Extensions.Logging; +using Vulthil.Messaging.Abstractions.Consumers; +using Vulthil.Messaging.IntegrationTest.ConsumerService.Infrastructure; +using Vulthil.Messaging.IntegrationTest.Contracts; + +namespace Vulthil.Messaging.IntegrationTest.ConsumerService.Failures; + +public sealed partial class PoisonCommandConsumer( + ILogger logger, + ReceivedMessageTracker tracker) : IConsumer +{ + public Task ConsumeAsync(IMessageContext messageContext, CancellationToken cancellationToken = default) + { + var attempt = tracker.RecordAttempt(messageContext.Message.Id); + LogAttempt(logger, messageContext.Message.Id, attempt); + + throw new InvalidOperationException( + $"Poison message {messageContext.Message.Id} always fails (attempt {attempt})."); + } + + [LoggerMessage(EventId = 1, Level = LogLevel.Warning, Message = "PoisonCommand {Id} attempt {Attempt} — about to fail")] + private static partial void LogAttempt(ILogger logger, Guid id, int attempt); +} diff --git a/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ConsumerService/Infrastructure/AuditConsumeFilter.cs b/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ConsumerService/Infrastructure/AuditConsumeFilter.cs new file mode 100644 index 0000000..0b89767 --- /dev/null +++ b/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ConsumerService/Infrastructure/AuditConsumeFilter.cs @@ -0,0 +1,13 @@ +using Vulthil.Messaging.Abstractions.Consumers; + +namespace Vulthil.Messaging.IntegrationTest.ConsumerService.Infrastructure; + +public sealed class AuditConsumeFilter(ReceivedMessageTracker tracker) : IConsumeFilter + where TMessage : notnull +{ + public async Task ConsumeAsync(IMessageContext context, ConsumeDelegate next) + { + tracker.Record("filter", typeof(TMessage).Name); + await next(context); + } +} diff --git a/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ConsumerService/Infrastructure/ReceivedMessageTracker.cs b/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ConsumerService/Infrastructure/ReceivedMessageTracker.cs new file mode 100644 index 0000000..90b7625 --- /dev/null +++ b/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ConsumerService/Infrastructure/ReceivedMessageTracker.cs @@ -0,0 +1,19 @@ +using System.Collections.Concurrent; + +namespace Vulthil.Messaging.IntegrationTest.ConsumerService.Infrastructure; + +public sealed class ReceivedMessageTracker +{ + private readonly ConcurrentDictionary> _messages = new(); + private readonly ConcurrentDictionary _attempts = new(); + + public void Record(string key, object message) + => _messages.GetOrAdd(key, _ => new ConcurrentQueue()).Enqueue(message); + + public IReadOnlyCollection Get(string key) + => _messages.TryGetValue(key, out var queue) ? queue.ToArray() : []; + + public int RecordAttempt(Guid id) => _attempts.AddOrUpdate(id, 1, (_, count) => count + 1); + + public int GetAttempts(Guid id) => _attempts.GetValueOrDefault(id); +} diff --git a/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ConsumerService/Program.cs b/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ConsumerService/Program.cs index 710eb15..ef804ac 100644 --- a/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ConsumerService/Program.cs +++ b/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ConsumerService/Program.cs @@ -1,5 +1,9 @@ using Vulthil.Messaging; -using Vulthil.Messaging.IntegrationTest.ConsumerService; +using Vulthil.Messaging.IntegrationTest.ConsumerService.Commands; +using Vulthil.Messaging.IntegrationTest.ConsumerService.Events; +using Vulthil.Messaging.IntegrationTest.ConsumerService.Failures; +using Vulthil.Messaging.IntegrationTest.ConsumerService.Infrastructure; +using Vulthil.Messaging.IntegrationTest.ConsumerService.Requests; using Vulthil.Messaging.IntegrationTest.Contracts; using Vulthil.Messaging.RabbitMq; @@ -32,6 +36,15 @@ message.UseRoutingKey("weather.get"); }); + messaging.ConfigureMessage(message => + { + message.ExchangeType = MessagingExchangeType.Direct; + message.UseRoutingKey("weather.fail"); + }); + + // Cross-cutting consume filter: records the message type of every delivery it wraps. + messaging.AddOpenConsumeFilter(typeof(AuditConsumeFilter<>)); + messaging.ConfigureQueue("weather-events", queue => { queue.AddConsumer(); @@ -43,12 +56,50 @@ queue.AddConsumer(); }); + // Point-to-point target: RecordWeatherCommandConsumer forwards an audit entry here via ctx.SendAsync. + messaging.ConfigureQueue("weather-audit", queue => + { + queue.AddConsumer(); + }); + messaging.ConfigureQueue("weather-requests", queue => { queue.Subscribe("weather.get"); queue.AddRequestConsumer(); }); + messaging.ConfigureQueue("failing-requests", queue => + { + queue.Subscribe("weather.fail"); + queue.AddRequestConsumer(); + }); + + // Polymorphic fan-out: a single StockChangedEvent delivery fires both the interface + // consumer (IInventoryEvent) and the concrete consumer (StockChangedEvent) on this queue. + messaging.ConfigureQueue("inventory-events", queue => + { + queue.SubscribeAll(typeof(IInventoryEvent).Assembly); + queue.AddConsumer(); + queue.AddConsumer(); + }); + + // Failure scenario: consumer fails the first attempts, then succeeds once retries kick in. + messaging.ConfigureQueue("flaky-commands", queue => + { + queue.UseRetry(retry => retry.Immediate(5)); + queue.AddConsumer(); + }); + + // Failure scenario: consumer always throws; after retries are exhausted the message is dead-lettered. + messaging.ConfigureQueue("poison-commands", queue => + { + queue.UseRetry(retry => retry.Immediate(1)); + queue.UseDeadLetterQueue( + queueName: "poison-commands.dead-letter", + exchangeName: "poison-commands.dead-letter-exchange"); + queue.AddConsumer(); + }); + messaging.UseRabbitMq("rabbitmq"); }); @@ -63,14 +114,11 @@ var api = app.MapGroup("/api"); -api.MapGet("received/events", (ReceivedMessageTracker tracker) => Results.Ok(tracker.Events)) - .WithName("GetReceivedEvents"); - -api.MapGet("received/commands", (ReceivedMessageTracker tracker) => Results.Ok(tracker.Commands)) - .WithName("GetReceivedCommands"); +api.MapGet("received/{key}", (string key, ReceivedMessageTracker tracker) => Results.Ok(tracker.Get(key))) + .WithName("GetReceivedMessages"); -api.MapGet("received/requests", (ReceivedMessageTracker tracker) => Results.Ok(tracker.Requests)) - .WithName("GetReceivedRequests"); +api.MapGet("attempts/{id:guid}", (Guid id, ReceivedMessageTracker tracker) => Results.Ok(tracker.GetAttempts(id))) + .WithName("GetConsumeAttempts"); app.MapDefaultEndpoints(); diff --git a/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ConsumerService/ReceivedMessageTracker.cs b/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ConsumerService/ReceivedMessageTracker.cs deleted file mode 100644 index 424eae0..0000000 --- a/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ConsumerService/ReceivedMessageTracker.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System.Collections.Concurrent; - -namespace Vulthil.Messaging.IntegrationTest.ConsumerService; - -public sealed class ReceivedMessageTracker -{ - private readonly ConcurrentQueue _events = new(); - private readonly ConcurrentQueue _commands = new(); - private readonly ConcurrentQueue _requests = new(); - - public void RecordEvent(object message) => _events.Enqueue(message); - public void RecordCommand(object message) => _commands.Enqueue(message); - public void RecordRequest(object message) => _requests.Enqueue(message); - - public IReadOnlyCollection Events => _events.ToArray(); - public IReadOnlyCollection Commands => _commands.ToArray(); - public IReadOnlyCollection Requests => _requests.ToArray(); -} diff --git a/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ConsumerService/GetWeatherRequestConsumer.cs b/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ConsumerService/Requests/GetWeatherRequestConsumer.cs similarity index 83% rename from tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ConsumerService/GetWeatherRequestConsumer.cs rename to tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ConsumerService/Requests/GetWeatherRequestConsumer.cs index 0be8c29..d84cad6 100644 --- a/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ConsumerService/GetWeatherRequestConsumer.cs +++ b/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ConsumerService/Requests/GetWeatherRequestConsumer.cs @@ -1,8 +1,9 @@ using Microsoft.Extensions.Logging; using Vulthil.Messaging.Abstractions.Consumers; +using Vulthil.Messaging.IntegrationTest.ConsumerService.Infrastructure; using Vulthil.Messaging.IntegrationTest.Contracts; -namespace Vulthil.Messaging.IntegrationTest.ConsumerService; +namespace Vulthil.Messaging.IntegrationTest.ConsumerService.Requests; public sealed partial class GetWeatherRequestConsumer( ILogger logger, @@ -12,7 +13,7 @@ public sealed partial class GetWeatherRequestConsumer( public Task ConsumeAsync(IMessageContext messageContext, CancellationToken cancellationToken = default) { LogReceived(logger, messageContext.Message.Location); - tracker.RecordRequest(messageContext.Message); + tracker.Record("requests", messageContext.Message); var response = new GetWeatherResponse( Location: messageContext.Message.Location, diff --git a/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.Contracts/FailingRequest.cs b/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.Contracts/FailingRequest.cs new file mode 100644 index 0000000..f08d947 --- /dev/null +++ b/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.Contracts/FailingRequest.cs @@ -0,0 +1,3 @@ +namespace Vulthil.Messaging.IntegrationTest.Contracts; + +public sealed record FailingRequest(string Reason); diff --git a/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.Contracts/FailingResponse.cs b/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.Contracts/FailingResponse.cs new file mode 100644 index 0000000..b67fbd2 --- /dev/null +++ b/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.Contracts/FailingResponse.cs @@ -0,0 +1,3 @@ +namespace Vulthil.Messaging.IntegrationTest.Contracts; + +public sealed record FailingResponse(string Detail); diff --git a/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.Contracts/FlakyCommand.cs b/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.Contracts/FlakyCommand.cs new file mode 100644 index 0000000..6899402 --- /dev/null +++ b/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.Contracts/FlakyCommand.cs @@ -0,0 +1,3 @@ +namespace Vulthil.Messaging.IntegrationTest.Contracts; + +public sealed record FlakyCommand(Guid Id, int FailUntilAttempt); diff --git a/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.Contracts/IInventoryEvent.cs b/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.Contracts/IInventoryEvent.cs new file mode 100644 index 0000000..116cfd2 --- /dev/null +++ b/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.Contracts/IInventoryEvent.cs @@ -0,0 +1,6 @@ +namespace Vulthil.Messaging.IntegrationTest.Contracts; + +public interface IInventoryEvent +{ + string Sku { get; } +} diff --git a/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.Contracts/PoisonCommand.cs b/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.Contracts/PoisonCommand.cs new file mode 100644 index 0000000..07fb83d --- /dev/null +++ b/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.Contracts/PoisonCommand.cs @@ -0,0 +1,3 @@ +namespace Vulthil.Messaging.IntegrationTest.Contracts; + +public sealed record PoisonCommand(Guid Id); diff --git a/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.Contracts/StockChangedEvent.cs b/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.Contracts/StockChangedEvent.cs new file mode 100644 index 0000000..f37f133 --- /dev/null +++ b/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.Contracts/StockChangedEvent.cs @@ -0,0 +1,3 @@ +namespace Vulthil.Messaging.IntegrationTest.Contracts; + +public sealed record StockChangedEvent(Guid Id, string Sku, int Delta) : IInventoryEvent; diff --git a/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.Contracts/UnansweredRequest.cs b/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.Contracts/UnansweredRequest.cs new file mode 100644 index 0000000..f8fb27b --- /dev/null +++ b/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.Contracts/UnansweredRequest.cs @@ -0,0 +1,3 @@ +namespace Vulthil.Messaging.IntegrationTest.Contracts; + +public sealed record UnansweredRequest(string Value); diff --git a/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.Contracts/UnansweredResponse.cs b/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.Contracts/UnansweredResponse.cs new file mode 100644 index 0000000..3d45480 --- /dev/null +++ b/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.Contracts/UnansweredResponse.cs @@ -0,0 +1,3 @@ +namespace Vulthil.Messaging.IntegrationTest.Contracts; + +public sealed record UnansweredResponse(string Value); diff --git a/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.Contracts/WeatherAuditEntry.cs b/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.Contracts/WeatherAuditEntry.cs new file mode 100644 index 0000000..685cb65 --- /dev/null +++ b/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.Contracts/WeatherAuditEntry.cs @@ -0,0 +1,3 @@ +namespace Vulthil.Messaging.IntegrationTest.Contracts; + +public sealed record WeatherAuditEntry(Guid SourceId, string Location); diff --git a/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ProducerService/Program.cs b/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ProducerService/Program.cs index 5e5f847..de3f050 100644 --- a/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ProducerService/Program.cs +++ b/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ProducerService/Program.cs @@ -12,6 +12,8 @@ builder.AddMessaging(messaging => { + messaging.ConfigureMessagingOptions(options => options.DefaultTimeout = TimeSpan.FromSeconds(15)); + messaging.ConfigureMessage(message => { message.ExchangeType = MessagingExchangeType.Fanout; @@ -29,6 +31,12 @@ message.UseRoutingKey("weather.get"); }); + messaging.ConfigureMessage(message => + { + message.ExchangeType = MessagingExchangeType.Direct; + message.UseRoutingKey("weather.fail"); + }); + messaging.UseRabbitMq("rabbitmq"); }); @@ -50,6 +58,13 @@ }) .WithName("PublishWeatherUpdatedEvent"); +api.MapPost("publish-inventory", async (StockChangedEvent message, IPublisher publisher, CancellationToken cancellationToken) => +{ + await publisher.PublishAsync(message, cancellationToken: cancellationToken); + return Results.Accepted(value: message); +}) +.WithName("PublishStockChangedEvent"); + api.MapPost("send-command", async (RecordWeatherCommand message, IPublisher publisher, CancellationToken cancellationToken) => { await publisher.PublishAsync(message, cancellationToken: cancellationToken); @@ -57,6 +72,20 @@ }) .WithName("SendRecordWeatherCommand"); +api.MapPost("send-flaky", async (FlakyCommand message, IPublisher publisher, CancellationToken cancellationToken) => +{ + await publisher.PublishAsync(message, cancellationToken: cancellationToken); + return Results.Accepted(value: message); +}) +.WithName("SendFlakyCommand"); + +api.MapPost("send-poison", async (PoisonCommand message, IPublisher publisher, CancellationToken cancellationToken) => +{ + await publisher.PublishAsync(message, cancellationToken: cancellationToken); + return Results.Accepted(value: message); +}) +.WithName("SendPoisonCommand"); + api.MapPost("request", async (GetWeatherRequest message, IRequester requester, CancellationToken cancellationToken) => { var result = await requester.RequestAsync(message, cancellationToken: cancellationToken); @@ -66,6 +95,32 @@ }) .WithName("RequestWeather"); +api.MapPost("request-failing", async (FailingRequest message, IRequester requester, CancellationToken cancellationToken) => +{ + var result = await requester.RequestAsync(message, cancellationToken: cancellationToken); + return result.IsSuccess + ? Results.Ok(result.Value) + : Results.Problem(detail: result.Error.Description, title: result.Error.Code, statusCode: StatusCodes.Status504GatewayTimeout); +}) +.WithName("RequestFailing"); + +api.MapPost("request-timeout", async (UnansweredRequest message, IRequester requester, CancellationToken cancellationToken) => +{ + var result = await requester.RequestAsync( + message, + context => + { + context.SetTimeout(TimeSpan.FromSeconds(2)); + return Task.CompletedTask; + }, + cancellationToken); + + return result.IsSuccess + ? Results.Ok(result.Value) + : Results.Problem(detail: result.Error.Description, title: result.Error.Code, statusCode: StatusCodes.Status504GatewayTimeout); +}) +.WithName("RequestTimeout"); + app.MapDefaultEndpoints(); await app.RunAsync(); diff --git a/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.Tests/AppHostFixture.cs b/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.Tests/AppHostFixture.cs index c0606ac..8661739 100644 --- a/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.Tests/AppHostFixture.cs +++ b/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.Tests/AppHostFixture.cs @@ -13,6 +13,9 @@ public sealed class AppHostFixture : IAsyncLifetime public HttpClient ProducerClient => _producerClient ?? throw new InvalidOperationException("AppHost has not been started."); public HttpClient ConsumerClient => _consumerClient ?? throw new InvalidOperationException("AppHost has not been started."); + public ValueTask GetRabbitMqConnectionStringAsync(CancellationToken cancellationToken = default) + => App.GetConnectionStringAsync("rabbitmq", cancellationToken); + public async ValueTask InitializeAsync() { var builder = await DistributedApplicationTestingBuilder.CreateAsync(); diff --git a/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.Tests/MessagingConfigurationTests.cs b/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.Tests/MessagingConfigurationTests.cs index 71303ac..ce30d26 100644 --- a/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.Tests/MessagingConfigurationTests.cs +++ b/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.Tests/MessagingConfigurationTests.cs @@ -1,4 +1,7 @@ +using System.Diagnostics; +using System.Net; using System.Net.Http.Json; +using RabbitMQ.Client; using Vulthil.Extensions.Testing; using Vulthil.Messaging.IntegrationTest.Contracts; using Vulthil.Results; @@ -71,6 +74,176 @@ public async Task SendingARequestReceivesAReplyFromTheConsumer() response.Location.ShouldBe(request.Location); } + [Fact] + public async Task PublishingAStockChangedEventFiresPolymorphicAndConcreteConsumers() + { + var cancellationToken = TestContext.Current.CancellationToken; + var @event = new StockChangedEvent(Guid.NewGuid(), "SKU-123", -5); + + using var publishResponse = await fixture.ProducerClient.PostAsJsonAsync( + "/api/publish-inventory", + @event, + cancellationToken); + publishResponse.IsSuccessStatusCode.ShouldBeTrue(); + + var polymorphicResult = await Polling.WaitAsync( + PollTimeout, + ct => TryFindMatchAsync(fixture.ConsumerClient, "/api/received/inventory.any", e => e.Id == @event.Id, ct), + PollInterval, + cancellationToken); + + var concreteResult = await Polling.WaitAsync( + PollTimeout, + ct => TryFindMatchAsync(fixture.ConsumerClient, "/api/received/inventory.stock-changed", e => e.Id == @event.Id, ct), + PollInterval, + cancellationToken); + + polymorphicResult.IsSuccess.ShouldBeTrue(); + concreteResult.IsSuccess.ShouldBeTrue(); + concreteResult.Value.Sku.ShouldBe(@event.Sku); + } + + [Fact] + public async Task SendingACommandForwardsAnAuditEntryViaSendAsync() + { + var cancellationToken = TestContext.Current.CancellationToken; + var command = new RecordWeatherCommand(Guid.NewGuid(), "Bergen", 7); + + using var sendResponse = await fixture.ProducerClient.PostAsJsonAsync( + "/api/send-command", + command, + cancellationToken); + sendResponse.IsSuccessStatusCode.ShouldBeTrue(); + + var auditResult = await Polling.WaitAsync( + PollTimeout, + ct => TryFindMatchAsync(fixture.ConsumerClient, "/api/received/audit", a => a.SourceId == command.Id, ct), + PollInterval, + cancellationToken); + + auditResult.IsSuccess.ShouldBeTrue(); + auditResult.Value.Location.ShouldBe(command.Location); + } + + [Fact] + public async Task PublishingAnEventInvokesTheCustomConsumeFilter() + { + var cancellationToken = TestContext.Current.CancellationToken; + var @event = new WeatherUpdatedEvent(Guid.NewGuid(), "Aarhus", 12); + + using var publishResponse = await fixture.ProducerClient.PostAsJsonAsync( + "/api/publish-event", + @event, + cancellationToken); + publishResponse.IsSuccessStatusCode.ShouldBeTrue(); + + var filterResult = await Polling.WaitAsync( + PollTimeout, + ct => TryFindMatchAsync(fixture.ConsumerClient, "/api/received/filter", name => name == nameof(WeatherUpdatedEvent), ct), + PollInterval, + cancellationToken); + + filterResult.IsSuccess.ShouldBeTrue(); + } + + [Fact] + public async Task FlakyConsumerEventuallySucceedsAfterRetries() + { + var cancellationToken = TestContext.Current.CancellationToken; + var command = new FlakyCommand(Guid.NewGuid(), FailUntilAttempt: 3); + + using var sendResponse = await fixture.ProducerClient.PostAsJsonAsync( + "/api/send-flaky", + command, + cancellationToken); + sendResponse.IsSuccessStatusCode.ShouldBeTrue(); + + var successResult = await Polling.WaitAsync( + PollTimeout, + ct => TryFindMatchAsync(fixture.ConsumerClient, "/api/received/flaky", c => c.Id == command.Id, ct), + PollInterval, + cancellationToken); + + successResult.IsSuccess.ShouldBeTrue(); + + var attempts = await fixture.ConsumerClient.GetFromJsonAsync($"/api/attempts/{command.Id}", cancellationToken); + attempts.ShouldBe(3); + } + + [Fact] + public async Task PoisonConsumerDeadLettersAfterRetriesAreExhausted() + { + var cancellationToken = TestContext.Current.CancellationToken; + var command = new PoisonCommand(Guid.NewGuid()); + + using var sendResponse = await fixture.ProducerClient.PostAsJsonAsync( + "/api/send-poison", + command, + cancellationToken); + sendResponse.IsSuccessStatusCode.ShouldBeTrue(); + + var connectionString = await fixture.GetRabbitMqConnectionStringAsync(cancellationToken); + connectionString.ShouldNotBeNull(); + + var factory = new ConnectionFactory { Uri = new Uri(connectionString) }; + await using var connection = await factory.CreateConnectionAsync(cancellationToken); + await using var channel = await connection.CreateChannelAsync(cancellationToken: cancellationToken); + + var deadLetterResult = await Polling.WaitAsync( + PollTimeout, + async ct => + { + var message = await channel.BasicGetAsync("poison-commands.dead-letter", autoAck: true, ct); + return message is not null + ? Result.Success(true) + : Result.Failure(Error.NotFound("DeadLetter.Empty", "No dead-lettered message yet.")); + }, + PollInterval, + cancellationToken); + + deadLetterResult.IsSuccess.ShouldBeTrue(); + + var attempts = await fixture.ConsumerClient.GetFromJsonAsync($"/api/attempts/{command.Id}", cancellationToken); + attempts.ShouldBe(2); + } + + [Fact] + public async Task RequestToAFaultingConsumerSurfacesAFailure() + { + var cancellationToken = TestContext.Current.CancellationToken; + var request = new FailingRequest("boom"); + + using var response = await fixture.ProducerClient.PostAsJsonAsync( + "/api/request-failing", + request, + cancellationToken); + + response.IsSuccessStatusCode.ShouldBeFalse(); + response.StatusCode.ShouldBe(HttpStatusCode.GatewayTimeout); + } + + [Fact] + public async Task RequestWithShortPerRequestTimeoutSurfacesATimeoutFailure() + { + var cancellationToken = TestContext.Current.CancellationToken; + var request = new UnansweredRequest("no-listener"); + + var stopwatch = Stopwatch.StartNew(); + using var response = await fixture.ProducerClient.PostAsJsonAsync( + "/api/request-timeout", + request, + cancellationToken); + stopwatch.Stop(); + + response.StatusCode.ShouldBe(HttpStatusCode.GatewayTimeout); + + var body = await response.Content.ReadAsStringAsync(cancellationToken); + body.ShouldContain("Request.Timeout"); + + // The 2s per-request timeout must apply, well under the 15s global default. + stopwatch.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(12)); + } + private static async Task> TryFindMatchAsync( HttpClient client, string endpoint, diff --git a/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.Tests/Vulthil.Messaging.IntegrationTest.Tests.csproj b/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.Tests/Vulthil.Messaging.IntegrationTest.Tests.csproj index f90705e..7c9855f 100644 --- a/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.Tests/Vulthil.Messaging.IntegrationTest.Tests.csproj +++ b/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.Tests/Vulthil.Messaging.IntegrationTest.Tests.csproj @@ -4,6 +4,7 @@ + diff --git a/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqRequesterTests.cs b/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqRequesterTests.cs new file mode 100644 index 0000000..ad3e66c --- /dev/null +++ b/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqRequesterTests.cs @@ -0,0 +1,72 @@ +using System.Diagnostics; +using RabbitMQ.Client; +using Vulthil.Messaging.RabbitMq.Publishing; +using Vulthil.Messaging.RabbitMq.Requests; +using Vulthil.xUnit; + +namespace Vulthil.Messaging.RabbitMq.Tests; + +public sealed class RabbitMqRequesterTests : BaseUnitTestCase +{ + private readonly Lazy _lazyTarget; + + private RabbitMqRequester Target => _lazyTarget.Value; + + public RabbitMqRequesterTests() + { + var options = new MessagingOptions(); + Use(options); + + var channelMock = GetMock(); + channelMock + .Setup(c => c.QueueDeclareAsync( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new QueueDeclareOk("callback.test", 0, 0)); + channelMock + .Setup(c => c.BasicConsumeAsync( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny>(), It.IsAny(), It.IsAny())) + .ReturnsAsync("consumer-tag"); + + GetMock() + .Setup(c => c.CreateChannelAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(channelMock.Object); + + Use(CreateInstance()); + + GetMock() + .Setup(p => p.InternalPublishAsync( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + _lazyTarget = new Lazy(CreateInstance); + } + + [Fact] + public async Task RequestAsyncReturnsTimeoutFailureWhenNoResponseArrivesWithinPerRequestTimeout() + { + // Arrange + var stopwatch = Stopwatch.StartNew(); + + // Act + var result = await Target.RequestAsync( + new TimeoutRequest("ping"), + context => + { + context.SetTimeout(TimeSpan.FromMilliseconds(200)); + return Task.CompletedTask; + }, + CancellationToken); + stopwatch.Stop(); + + // Assert + result.IsFailure.ShouldBeTrue(); + result.Error.Code.ShouldBe("Messaging.Request.Timeout"); + stopwatch.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(5)); + } + + private sealed record TimeoutRequest(string Value); + + private sealed record TimeoutResponse(string Value); +} diff --git a/tests/Vulthil.Messaging.Tests/Filters/DefaultFilterRegistrationTests.cs b/tests/Vulthil.Messaging.Tests/Filters/DefaultFilterRegistrationTests.cs index 7b7e93a..2f5808f 100644 --- a/tests/Vulthil.Messaging.Tests/Filters/DefaultFilterRegistrationTests.cs +++ b/tests/Vulthil.Messaging.Tests/Filters/DefaultFilterRegistrationTests.cs @@ -66,8 +66,51 @@ public void LoggingFilterRegistersBeforeUserFiltersSoItStaysOutermost() descriptors[1].ImplementationType.ShouldBe(typeof(UserFilter<>)); } + [Fact] + public void AddConsumeFilterThrowsWhenTypeImplementsNoConsumeFilterInterface() + { + // Arrange + var builder = CreateHostBuilder(); + + // Act & Assert + var ex = Should.Throw(() => + builder.AddMessaging(m => m.AddConsumeFilter())); + + ex.Message.ShouldContain("must implement at least one"); + } + + [Fact] + public void AddOpenConsumeFilterThrowsWhenTypeIsNotOpenGeneric() + { + // Arrange + var builder = CreateHostBuilder(); + + // Act & Assert + var ex = Should.Throw(() => + builder.AddMessaging(m => m.AddOpenConsumeFilter(typeof(NotAFilter)))); + + ex.Message.ShouldContain("must be an open generic type"); + } + + [Fact] + public void AddOpenConsumeFilterThrowsWhenOpenGenericDoesNotImplementConsumeFilter() + { + // Arrange + var builder = CreateHostBuilder(); + + // Act & Assert + var ex = Should.Throw(() => + builder.AddMessaging(m => m.AddOpenConsumeFilter(typeof(NotAFilterOpen<>)))); + + ex.Message.ShouldContain("must implement"); + } + private sealed class UserFilter : IConsumeFilter where TMessage : notnull { public Task ConsumeAsync(IMessageContext context, ConsumeDelegate next) => next(context); } + + private sealed record NotAFilter(string Name); + + private sealed record NotAFilterOpen(TMessage Payload); } diff --git a/tests/Vulthil.Messaging.Tests/MessagingConfiguratiorTests.cs b/tests/Vulthil.Messaging.Tests/MessagingConfiguratiorTests.cs index 0f13f1a..2b9af39 100644 --- a/tests/Vulthil.Messaging.Tests/MessagingConfiguratiorTests.cs +++ b/tests/Vulthil.Messaging.Tests/MessagingConfiguratiorTests.cs @@ -168,8 +168,29 @@ public void RegisterCorrelationIdFormatterShouldStoreFormatterForType() .ShouldBe(testMessage.Id); } + [Fact] + public void RegisteringTwoMessageTypesWithTheSameUrnThrows() + { + // Arrange + var options = new MessagingOptions(); + var messagingConfigurator = new MessagingConfigurator(Target, options); + messagingConfigurator.ConfigureMessage(c => c.Urn = new Uri("urn:message:duplicate")); + + // Act + var ex = Should.Throw(() => + messagingConfigurator.ConfigureMessage(c => c.Urn = new Uri("urn:message:duplicate"))); + + // Assert + ex.Message.ShouldContain("already registered"); + ex.Message.ShouldContain(typeof(UrnBeta).FullName!); + } + private sealed record TestMessage { public string Id { get; set; } = string.Empty; } + + private sealed record UrnAlpha(string Value); + + private sealed record UrnBeta(string Value); } diff --git a/tests/Vulthil.Messaging.Tests/RetryPolicyConfiguratorTests.cs b/tests/Vulthil.Messaging.Tests/RetryPolicyConfiguratorTests.cs new file mode 100644 index 0000000..75f9f35 --- /dev/null +++ b/tests/Vulthil.Messaging.Tests/RetryPolicyConfiguratorTests.cs @@ -0,0 +1,35 @@ +using Vulthil.Messaging.Queues; +using Vulthil.xUnit; + +namespace Vulthil.Messaging.Tests; + +public sealed class RetryPolicyConfiguratorTests : BaseUnitTestCase +{ + protected override RetryPolicyConfigurator CreateInstance() => new(); + + [Fact] + public void UseJitterThrowsWhenFactorIsAboveOne() + { + // Act & Assert + Should.Throw(() => Target.UseJitter(1.5)); + } + + [Fact] + public void UseJitterThrowsWhenFactorIsBelowZero() + { + // Act & Assert + Should.Throw(() => Target.UseJitter(-0.1)); + } + + [Fact] + public void ImmediateConfiguresZeroDelayIntervals() + { + // Act + Target.Immediate(3); + + // Assert + Target.RetryLimit.ShouldBe(3); + Target.Intervals.Count.ShouldBe(3); + Target.Intervals.ShouldAllBe(interval => interval == TimeSpan.Zero); + } +} From e35450e8f1fb85e8875233e9c994d5fb6b62c7e7 Mon Sep 17 00:00:00 2001 From: Vulthil Date: Sat, 30 May 2026 22:52:45 +0200 Subject: [PATCH 12/42] docs: remove placeholder XML doc boilerplate across src, tests, and samples --- samples/AppHost/ServiceDefaults/Extensions.cs | 18 -- .../AppHost/ServiceDefaults/ServiceNames.cs | 15 -- .../WebApi.Application/DependencyInjection.cs | 6 - .../Create/CreateMainEntityCommand.cs | 9 - .../Create/MainEntityCreatedEventHandler.cs | 9 - .../GetAll/GetMainEntityByIdQuery.cs | 9 - .../GetById/GetMainEntityByIdQuery.cs | 21 -- .../MainEntityNameUpdatedEventHandler.cs | 6 - .../Update/UpdateMainEntityNameCommand.cs | 9 - ...inEntityCreatedIntegrationEventConsumer.cs | 15 -- .../GetInProgressQueryHandler.cs | 6 - .../SideEffects/SideEffectDto.cs | 15 -- .../Events/MainEntityCreatedEvent.cs | 3 - .../Events/MainEntityNameUpdatedEvent.cs | 3 - .../WebApi.Domain/MainEntities/MainEntity.cs | 21 -- .../WebApi.Domain/SideEffects/SideEffect.cs | 18 -- .../SideEffects/SideEffectErrors.cs | 6 - .../WebApi.Domain/SideEffects/Status.cs | 24 --- .../MainEntityConfiguration.cs | 6 - .../SideEffectEntityConfiguration.cs | 6 - .../Fixtures/PostgreSqlTestContainer.cs | 24 --- .../SideEffectIntegrationTests.cs | 6 - samples/WebApi/WebApi/MainEntity/Create.cs | 12 -- samples/WebApi/WebApi/MainEntity/GetAll.cs | 9 - samples/WebApi/WebApi/MainEntity/GetById.cs | 9 - .../ExchangeTypeMapper.cs | 10 +- .../Requests/MessageResult.cs | 26 ++- .../Requests/ResponseWaiter.cs | 4 +- src/Vulthil.xUnit/BaseIntegrationTestCase.cs | 4 +- .../ExchangeTypeMapperTests.cs | 18 -- .../MessageContextTests.cs | 15 -- .../MessageTypeCacheTests.cs | 3 - .../RabbitMqConstantsTests.cs | 24 --- .../RabbitMqPublisherTests.cs | 15 -- .../RabbitMqSendEndpointProviderTests.cs | 3 - .../MessagingConfiguratiorTests.cs | 6 - .../MessagingExchangeTypeTests.cs | 18 -- .../Errors/ErrorTests.cs | 18 -- .../Results/BindResultBaseTestCase.cs | 141 ------------- .../Results/BindResultExtensionsTests.cs | 99 --------- .../Results/MapResultBaseTestCase.cs | 27 --- .../Results/MapResultExtensionsTests.cs | 51 ----- .../Results/MatchResultExtensionsTests.cs | 195 ------------------ .../Results/ResultBaseTestCase.cs | 108 ---------- .../Results/ResultTests.cs | 24 --- .../Results/TapResultBaseTestCase.cs | 24 --- .../Results/TapResultExtensionsTests.cs | 75 ------- .../Results/ToResultExtensionsTests.cs | 27 --- .../ValidationErrorTests.cs | 9 - .../DomainEventPublisherTests.cs | 21 -- .../ValidationPipelineBehaviorTests.cs | 15 -- .../Core/AggregateRootTests.cs | 18 -- .../Core/DomainExceptionTests.cs | 6 - .../Core/IDomainEventHandlerTests.cs | 9 - 54 files changed, 35 insertions(+), 1263 deletions(-) diff --git a/samples/AppHost/ServiceDefaults/Extensions.cs b/samples/AppHost/ServiceDefaults/Extensions.cs index 0d5c0e3..9a95ddb 100644 --- a/samples/AppHost/ServiceDefaults/Extensions.cs +++ b/samples/AppHost/ServiceDefaults/Extensions.cs @@ -10,17 +10,8 @@ namespace ServiceDefaults; -// Adds common .NET Aspire services: service discovery, resilience, health checks, and OpenTelemetry. -// This project should be referenced by each service project in your solution. -// To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults -/// -/// Represents the Extensions. -/// public static class Extensions { - /// - /// Executes this member. - /// public static TBuilder AddServiceDefaults(this TBuilder builder) where TBuilder : IHostApplicationBuilder { builder.ConfigureOpenTelemetry(); @@ -47,9 +38,6 @@ public static TBuilder AddServiceDefaults(this TBuilder builder) where return builder; } - /// - /// Executes this member. - /// public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) where TBuilder : IHostApplicationBuilder { builder.Logging.AddOpenTelemetry(logging => @@ -99,9 +87,6 @@ private static TBuilder AddOpenTelemetryExporters(this TBuilder builde return builder; } - /// - /// Executes this member. - /// public static TBuilder AddDefaultHealthChecks(this TBuilder builder) where TBuilder : IHostApplicationBuilder { builder.Services.AddRequestTimeouts(); @@ -123,9 +108,6 @@ public static TBuilder AddDefaultHealthChecks(this TBuilder builder) w return builder; } - /// - /// Executes this member. - /// public static WebApplication MapDefaultEndpoints(this WebApplication app) { app.UseRequestTimeouts(); diff --git a/samples/AppHost/ServiceDefaults/ServiceNames.cs b/samples/AppHost/ServiceDefaults/ServiceNames.cs index b1eab18..b457b2c 100644 --- a/samples/AppHost/ServiceDefaults/ServiceNames.cs +++ b/samples/AppHost/ServiceDefaults/ServiceNames.cs @@ -1,26 +1,11 @@ namespace ServiceDefaults; -/// -/// Represents the ServiceNames. -/// public static class ServiceNames { - /// - /// Represents this member. - /// public const string RabbitMqServiceName = "rabbitMq"; - /// - /// Represents this member. - /// public const string PostgresSqlServerServiceName = "postgres"; - /// - /// Represents this member. - /// public const string WebApiServiceName = "webapi"; - /// - /// Represents this member. - /// public const string WebAppServiceName = "webapp"; } diff --git a/samples/WebApi/WebApi.Application/DependencyInjection.cs b/samples/WebApi/WebApi.Application/DependencyInjection.cs index b91ff15..85b5e2e 100644 --- a/samples/WebApi/WebApi.Application/DependencyInjection.cs +++ b/samples/WebApi/WebApi.Application/DependencyInjection.cs @@ -2,14 +2,8 @@ using Vulthil.SharedKernel.Application; namespace WebApi.Application; -/// -/// Represents the DependencyInjection. -/// public static class DependencyInjection { - /// - /// Executes this member. - /// public static IServiceCollection AddApplicationLayer(this IServiceCollection services) { services.AddApplication(appOptions => diff --git a/samples/WebApi/WebApi.Application/MainEntities/Create/CreateMainEntityCommand.cs b/samples/WebApi/WebApi.Application/MainEntities/Create/CreateMainEntityCommand.cs index 0ee5d29..2a172c9 100644 --- a/samples/WebApi/WebApi.Application/MainEntities/Create/CreateMainEntityCommand.cs +++ b/samples/WebApi/WebApi.Application/MainEntities/Create/CreateMainEntityCommand.cs @@ -5,22 +5,13 @@ namespace WebApi.Application.MainEntities.Create; -/// -/// Represents the CreateMainEntityCommand. -/// public sealed record CreateMainEntityCommand(string Name) : ITransactionalCommand>; -/// -/// Represents the CreateMainEntityCommandHandler. -/// public sealed class CreateMainEntityCommandHandler(ILogger logger, IWebApiDbContext dbContext) : ICommandHandler> { private readonly ILogger _logger = logger; private readonly IWebApiDbContext _dbContext = dbContext; - /// - /// Executes this member. - /// public async Task> HandleAsync(CreateMainEntityCommand command, CancellationToken cancellationToken = default) { _logger.LogInformation("Creating MainEntity With name: {Name}", command.Name); diff --git a/samples/WebApi/WebApi.Application/MainEntities/Create/MainEntityCreatedEventHandler.cs b/samples/WebApi/WebApi.Application/MainEntities/Create/MainEntityCreatedEventHandler.cs index acfbc69..994e311 100644 --- a/samples/WebApi/WebApi.Application/MainEntities/Create/MainEntityCreatedEventHandler.cs +++ b/samples/WebApi/WebApi.Application/MainEntities/Create/MainEntityCreatedEventHandler.cs @@ -5,17 +5,11 @@ namespace WebApi.Application.MainEntities.Create; -/// -/// Represents the MainEntityCreatedEventHandler. -/// public sealed class MainEntityCreatedEventHandler(ILogger logger, IPublisher publisher) : IDomainEventHandler { private readonly ILogger _logger = logger; private readonly IPublisher _publisher = publisher; - /// - /// Executes this member. - /// public async Task HandleAsync(MainEntityCreatedEvent notification, CancellationToken cancellationToken = default) { _logger.LogInformation("MainEntityCreated: {Id}", notification.Id); @@ -23,7 +17,4 @@ public async Task HandleAsync(MainEntityCreatedEvent notification, CancellationT } } -/// -/// Represents the MainEntityCreatedIntegrationEvent. -/// public sealed record MainEntityCreatedIntegrationEvent(Guid Id); diff --git a/samples/WebApi/WebApi.Application/MainEntities/GetAll/GetMainEntityByIdQuery.cs b/samples/WebApi/WebApi.Application/MainEntities/GetAll/GetMainEntityByIdQuery.cs index 75bac0c..063c373 100644 --- a/samples/WebApi/WebApi.Application/MainEntities/GetAll/GetMainEntityByIdQuery.cs +++ b/samples/WebApi/WebApi.Application/MainEntities/GetAll/GetMainEntityByIdQuery.cs @@ -8,23 +8,14 @@ namespace WebApi.Application.MainEntities.GetById; -/// -/// Represents the GetMainEntities. -/// public sealed record GetMainEntities : IQuery>>; -/// -/// Represents the GetMainEntitiesQueryHandler. -/// public sealed class GetMainEntitiesQueryHandler(ILogger logger, IWebApiDbContext dbContext, IRequester requester) : IQueryHandler>> { private readonly ILogger _logger = logger; private readonly IWebApiDbContext _dbContext = dbContext; private readonly IRequester _requester = requester; - /// - /// Executes this member. - /// public async Task>> HandleAsync(GetMainEntities query, CancellationToken cancellationToken = default) { _logger.LogInformation("Querying all MainEntities"); diff --git a/samples/WebApi/WebApi.Application/MainEntities/GetById/GetMainEntityByIdQuery.cs b/samples/WebApi/WebApi.Application/MainEntities/GetById/GetMainEntityByIdQuery.cs index 2b761fa..6004732 100644 --- a/samples/WebApi/WebApi.Application/MainEntities/GetById/GetMainEntityByIdQuery.cs +++ b/samples/WebApi/WebApi.Application/MainEntities/GetById/GetMainEntityByIdQuery.cs @@ -9,26 +9,11 @@ namespace WebApi.Application.MainEntities.GetById; -/// -/// Represents the GetMainEntityByIdQuery. -/// public sealed record GetMainEntityByIdQuery(Guid Id) : IQuery>; -/// -/// Represents the MainEntityDto. -/// public sealed record MainEntityDto { - /// - /// Gets or sets this member value. - /// public Guid Id { get; private set; } - /// - /// Gets or sets this member value. - /// public string Name { get; private set; } - /// - /// Gets or sets this member value. - /// public required List SideEffects { get; init; } private MainEntityDto(Guid id, string name) => (Id, Name) = (id, name); @@ -39,18 +24,12 @@ internal static MainEntityDto FromModel(MainEntity mainEntity, List -/// Represents the GetMainEntityQueryHandler. -/// public sealed class GetMainEntityQueryHandler(ILogger logger, IWebApiDbContext dbContext, IRequester requester) : IQueryHandler> { private readonly ILogger _logger = logger; private readonly IWebApiDbContext _dbContext = dbContext; private readonly IRequester _requester = requester; - /// - /// Executes this member. - /// public async Task> HandleAsync(GetMainEntityByIdQuery query, CancellationToken cancellationToken = default) { _logger.LogInformation("Querying MainEntity With Id: {Id}", query.Id); diff --git a/samples/WebApi/WebApi.Application/MainEntities/Update/MainEntityNameUpdatedEventHandler.cs b/samples/WebApi/WebApi.Application/MainEntities/Update/MainEntityNameUpdatedEventHandler.cs index db3853d..276f76f 100644 --- a/samples/WebApi/WebApi.Application/MainEntities/Update/MainEntityNameUpdatedEventHandler.cs +++ b/samples/WebApi/WebApi.Application/MainEntities/Update/MainEntityNameUpdatedEventHandler.cs @@ -4,16 +4,10 @@ namespace WebApi.Application.MainEntities.Update; -/// -/// Represents the MainEntityNameUpdatedEventHandler. -/// public sealed class MainEntityNameUpdatedEventHandler(ILogger logger) : IDomainEventHandler { private readonly ILogger _logger = logger; - /// - /// Executes this member. - /// public Task HandleAsync(MainEntityNameUpdatedEvent notification, CancellationToken cancellationToken = default) { _logger.LogInformation("WebApiEntity Name updated to: {Name}", notification.Name); diff --git a/samples/WebApi/WebApi.Application/MainEntities/Update/UpdateMainEntityNameCommand.cs b/samples/WebApi/WebApi.Application/MainEntities/Update/UpdateMainEntityNameCommand.cs index ded1030..34f7c58 100644 --- a/samples/WebApi/WebApi.Application/MainEntities/Update/UpdateMainEntityNameCommand.cs +++ b/samples/WebApi/WebApi.Application/MainEntities/Update/UpdateMainEntityNameCommand.cs @@ -6,22 +6,13 @@ namespace WebApi.Application.MainEntities.Update; -/// -/// Represents the UpdateMainEntityNameCommand. -/// public sealed record UpdateMainEntityNameCommand(Guid Id, string Name) : ITransactionalCommand; -/// -/// Represents the UpdateMainEntityNameCommandHandler. -/// public sealed class UpdateMainEntityNameCommandHandler(ILogger logger, IWebApiDbContext dbContext) : ICommandHandler { private readonly ILogger _logger = logger; private readonly IWebApiDbContext _dbContext = dbContext; - /// - /// Executes this member. - /// public async Task HandleAsync(UpdateMainEntityNameCommand command, CancellationToken cancellationToken = default) { _logger.LogInformation("Updating MainEntity With name: {Name}", command.Name); diff --git a/samples/WebApi/WebApi.Application/SideEffects/Create/MainEntityCreatedIntegrationEventConsumer.cs b/samples/WebApi/WebApi.Application/SideEffects/Create/MainEntityCreatedIntegrationEventConsumer.cs index f5f6f0b..0a9dc3e 100644 --- a/samples/WebApi/WebApi.Application/SideEffects/Create/MainEntityCreatedIntegrationEventConsumer.cs +++ b/samples/WebApi/WebApi.Application/SideEffects/Create/MainEntityCreatedIntegrationEventConsumer.cs @@ -6,18 +6,12 @@ namespace WebApi.Application.SideEffects.Create; -/// -/// Represents the MainEntityCreatedIntegrationEventConsumer. -/// public sealed class MainEntityCreatedIntegrationEventConsumer(ILogger logger, IWebApiDbContext webApiDbContext, TimeProvider timeProvider) : IConsumer { private readonly ILogger _logger = logger; private readonly IWebApiDbContext _webApiDbContext = webApiDbContext; private readonly TimeProvider _timeProvider = timeProvider; - /// - /// Executes this member. - /// public async Task ConsumeAsync(IMessageContext messageContext, CancellationToken cancellationToken = default) { _logger.LogInformation("Received MainEntityCreatedIntegrationEvent with Id: {Id} and RoutingKey: {RoutingKey}", messageContext.Message.Id, messageContext.RoutingKey); @@ -29,18 +23,12 @@ public async Task ConsumeAsync(IMessageContext -/// Represents the SideEffectRequestConsumer. -/// public sealed class SideEffectRequestConsumer(ILogger logger, IWebApiDbContext webApiDbContext) : IRequestConsumer> { private readonly ILogger _logger = logger; private readonly IWebApiDbContext _webApiDbContext = webApiDbContext; - /// - /// Executes this member. - /// public async Task> ConsumeAsync(IMessageContext messageContext, CancellationToken cancellationToken = default) { _logger.LogInformation("Received MainEntityCreatedIntegrationEvent with Id: {Id} and RoutingKey: {RoutingKey}", messageContext.Message.Id, messageContext.RoutingKey); @@ -53,7 +41,4 @@ public async Task> ConsumeAsync(IMessageContext -/// Represents the GetSideEffectsBelongingToMainEntity. -/// public sealed record GetSideEffectsBelongingToMainEntity(Guid Id); diff --git a/samples/WebApi/WebApi.Application/SideEffects/GetInProgress/GetInProgressQueryHandler.cs b/samples/WebApi/WebApi.Application/SideEffects/GetInProgress/GetInProgressQueryHandler.cs index 7400e91..e54f3e6 100644 --- a/samples/WebApi/WebApi.Application/SideEffects/GetInProgress/GetInProgressQueryHandler.cs +++ b/samples/WebApi/WebApi.Application/SideEffects/GetInProgress/GetInProgressQueryHandler.cs @@ -5,18 +5,12 @@ namespace WebApi.Application.SideEffects.GetInProgress; -/// -/// Represents the GetInProgressQuery. -/// public sealed record GetInProgressQuery : IQuery>>; internal class GetInProgressQueryHandler(IWebApiDbContext webApiDbContext) : IQueryHandler>> { private readonly IWebApiDbContext _webApiDbContext = webApiDbContext; - /// - /// Executes this member. - /// public async Task>> HandleAsync(GetInProgressQuery request, CancellationToken cancellationToken = default) { var list = await _webApiDbContext.SideEffects diff --git a/samples/WebApi/WebApi.Application/SideEffects/SideEffectDto.cs b/samples/WebApi/WebApi.Application/SideEffects/SideEffectDto.cs index dba3c93..b49735a 100644 --- a/samples/WebApi/WebApi.Application/SideEffects/SideEffectDto.cs +++ b/samples/WebApi/WebApi.Application/SideEffects/SideEffectDto.cs @@ -2,27 +2,12 @@ namespace WebApi.Application.SideEffects; -/// -/// Represents the SideEffectDto. -/// public sealed record SideEffectDto { - /// - /// Gets or sets this member value. - /// public Guid Id { get; init; } - /// - /// Gets or sets this member value. - /// public Guid MainEntityId { get; init; } - /// - /// Gets or sets this member value. - /// public required Status Status { get; init; } - /// - /// Executes this member. - /// public static SideEffectDto FromModel(SideEffect sideEffect) => new() { Id = sideEffect.Id.Value, diff --git a/samples/WebApi/WebApi.Domain/MainEntities/Events/MainEntityCreatedEvent.cs b/samples/WebApi/WebApi.Domain/MainEntities/Events/MainEntityCreatedEvent.cs index 724d171..0812ca2 100644 --- a/samples/WebApi/WebApi.Domain/MainEntities/Events/MainEntityCreatedEvent.cs +++ b/samples/WebApi/WebApi.Domain/MainEntities/Events/MainEntityCreatedEvent.cs @@ -2,7 +2,4 @@ namespace WebApi.Domain.MainEntities.Events; -/// -/// Represents the MainEntityCreatedEvent. -/// public sealed record MainEntityCreatedEvent(MainEntityId Id) : IDomainEvent; diff --git a/samples/WebApi/WebApi.Domain/MainEntities/Events/MainEntityNameUpdatedEvent.cs b/samples/WebApi/WebApi.Domain/MainEntities/Events/MainEntityNameUpdatedEvent.cs index 550b1bf..8a45736 100644 --- a/samples/WebApi/WebApi.Domain/MainEntities/Events/MainEntityNameUpdatedEvent.cs +++ b/samples/WebApi/WebApi.Domain/MainEntities/Events/MainEntityNameUpdatedEvent.cs @@ -2,7 +2,4 @@ namespace WebApi.Domain.MainEntities.Events; -/// -/// Represents the MainEntityNameUpdatedEvent. -/// public sealed record MainEntityNameUpdatedEvent(MainEntityId Id, string Name) : IDomainEvent; diff --git a/samples/WebApi/WebApi.Domain/MainEntities/MainEntity.cs b/samples/WebApi/WebApi.Domain/MainEntities/MainEntity.cs index 948fd8d..42da792 100644 --- a/samples/WebApi/WebApi.Domain/MainEntities/MainEntity.cs +++ b/samples/WebApi/WebApi.Domain/MainEntities/MainEntity.cs @@ -4,24 +4,12 @@ namespace WebApi.Domain.MainEntities; -/// -/// Represents the MainEntityId. -/// public sealed record MainEntityId(Guid Value); -/// -/// Represents the MainEntity. -/// public class MainEntity : AggregateRoot { - /// - /// Gets or sets this member value. - /// public string Name { get; private set; } private MainEntity(string name) : base(new(Guid.CreateVersion7())) => Name = name; - /// - /// Executes this member. - /// public static MainEntity Create(string name) { var mainEntity = new MainEntity(name); @@ -30,9 +18,6 @@ public static MainEntity Create(string name) return mainEntity; } - /// - /// Executes this member. - /// public void UpdateName(string name) { Name = name; @@ -40,13 +25,7 @@ public void UpdateName(string name) } } -/// -/// Represents the MainEntityErrors. -/// public static class MainEntityErrors { - /// - /// Executes this member. - /// public static Error NotFound(Guid id) => Error.NotFound("MainEntity.NotFound", $"Entity with Id {id} was not found."); } diff --git a/samples/WebApi/WebApi.Domain/SideEffects/SideEffect.cs b/samples/WebApi/WebApi.Domain/SideEffects/SideEffect.cs index 8f454d1..480cf99 100644 --- a/samples/WebApi/WebApi.Domain/SideEffects/SideEffect.cs +++ b/samples/WebApi/WebApi.Domain/SideEffects/SideEffect.cs @@ -3,23 +3,11 @@ namespace WebApi.Domain.SideEffects; -/// -/// Represents the SideEffectId. -/// public sealed record SideEffectId(Guid Value); -/// -/// Represents the SideEffect. -/// public sealed class SideEffect : AggregateRoot { - /// - /// Gets or sets this member value. - /// public Guid MainEntityId { get; private set; } - /// - /// Gets or sets this member value. - /// public Status Status { get; private set; } private SideEffect(Guid mainEntityId, Status status) : base(new(Guid.CreateVersion7())) @@ -28,9 +16,6 @@ private SideEffect(Guid mainEntityId, Status status) : base(new(Guid.CreateVersi Status = status; } - /// - /// Executes this member. - /// public static SideEffect Create(Guid mainEntityId, DateTimeOffset startTime) { var inProgressStatus = Status.InProgress(startTime); @@ -38,9 +23,6 @@ public static SideEffect Create(Guid mainEntityId, DateTimeOffset startTime) return new SideEffect(mainEntityId, inProgressStatus); } - /// - /// Executes this member. - /// public Result Complete(DateTimeOffset completedTime, int value) { if (Status is Status.CompletedStatus) diff --git a/samples/WebApi/WebApi.Domain/SideEffects/SideEffectErrors.cs b/samples/WebApi/WebApi.Domain/SideEffects/SideEffectErrors.cs index 19248c1..d5c9a22 100644 --- a/samples/WebApi/WebApi.Domain/SideEffects/SideEffectErrors.cs +++ b/samples/WebApi/WebApi.Domain/SideEffects/SideEffectErrors.cs @@ -2,13 +2,7 @@ namespace WebApi.Domain.SideEffects; -/// -/// Represents the SideEffectErrors. -/// public static class SideEffectErrors { - /// - /// Executes this member. - /// public static Error AlreadyCompleted => Error.Conflict("SideEffect.AlreadyCompleted", "The side effect has already been completed and cannot be updated again."); } diff --git a/samples/WebApi/WebApi.Domain/SideEffects/Status.cs b/samples/WebApi/WebApi.Domain/SideEffects/Status.cs index f3c7f87..b1c214f 100644 --- a/samples/WebApi/WebApi.Domain/SideEffects/Status.cs +++ b/samples/WebApi/WebApi.Domain/SideEffects/Status.cs @@ -2,48 +2,24 @@ namespace WebApi.Domain.SideEffects; -/// -/// Represents the Status. -/// [JsonDerivedType(typeof(FailedStatus), "Failed")] [JsonDerivedType(typeof(InProgressStatus), "InProgress")] [JsonDerivedType(typeof(CompletedStatus), "Completed")] public abstract record Status { - /// - /// Represents the FailedStatus. - /// public sealed record FailedStatus(DateTimeOffset FailedTime, string ErrorMessage) : Status; - /// - /// Represents the InProgressStatus. - /// public sealed record InProgressStatus(DateTimeOffset ProgressTime) : Status; - /// - /// Represents the CompletedStatus. - /// public sealed record CompletedStatus(DateTimeOffset CompletedTime, int Value) : Status; } -/// -/// Represents the StatusFactories. -/// public static class StatusFactories { extension(Status) { - /// - /// Executes this member. - /// public static Status Failed(DateTimeOffset failedTime, string errorMessage) => new Status.FailedStatus(failedTime, errorMessage); - /// - /// Executes this member. - /// public static Status InProgress(DateTimeOffset progressTime) => new Status.InProgressStatus(progressTime); - /// - /// Executes this member. - /// public static Status Completed(DateTimeOffset completedTime, int value) => new Status.CompletedStatus(completedTime, value); } diff --git a/samples/WebApi/WebApi.Infrastructure/Data/EntityConfigurations/MainEntityConfiguration.cs b/samples/WebApi/WebApi.Infrastructure/Data/EntityConfigurations/MainEntityConfiguration.cs index 4661b91..f7bd35b 100644 --- a/samples/WebApi/WebApi.Infrastructure/Data/EntityConfigurations/MainEntityConfiguration.cs +++ b/samples/WebApi/WebApi.Infrastructure/Data/EntityConfigurations/MainEntityConfiguration.cs @@ -4,14 +4,8 @@ namespace WebApi.Infrastructure.Data.EntityConfigurations; -/// -/// Represents the MainEntityConfiguration. -/// public sealed class MainEntityConfiguration : IEntityTypeConfiguration { - /// - /// Executes this member. - /// public void Configure(EntityTypeBuilder builder) { builder.HasKey(e => e.Id); diff --git a/samples/WebApi/WebApi.Infrastructure/Data/EntityConfigurations/SideEffectEntityConfiguration.cs b/samples/WebApi/WebApi.Infrastructure/Data/EntityConfigurations/SideEffectEntityConfiguration.cs index 8b166ce..26e0e26 100644 --- a/samples/WebApi/WebApi.Infrastructure/Data/EntityConfigurations/SideEffectEntityConfiguration.cs +++ b/samples/WebApi/WebApi.Infrastructure/Data/EntityConfigurations/SideEffectEntityConfiguration.cs @@ -6,14 +6,8 @@ namespace WebApi.Infrastructure.Data.EntityConfigurations; -/// -/// Represents the SideEffectEntityConfiguration. -/// public sealed class SideEffectEntityConfiguration : IEntityTypeConfiguration { - /// - /// Executes this member. - /// public void Configure(EntityTypeBuilder builder) { builder.HasKey(e => e.Id); diff --git a/samples/WebApi/WebApi.Tests/Fixtures/PostgreSqlTestContainer.cs b/samples/WebApi/WebApi.Tests/Fixtures/PostgreSqlTestContainer.cs index 67b25ab..8546ca2 100644 --- a/samples/WebApi/WebApi.Tests/Fixtures/PostgreSqlTestContainer.cs +++ b/samples/WebApi/WebApi.Tests/Fixtures/PostgreSqlTestContainer.cs @@ -14,46 +14,22 @@ internal sealed class PostgreSqlTestContainer(IMessageSink messageSink) : TestDa { private readonly PostgreSqlBuilder _builder = new PostgreSqlBuilder("postgres:18.1") .WithPassword("webapi"); - /// - /// Executes this member. - /// protected override PostgreSqlBuilder Configure() => _builder; - /// - /// Represents this member. - /// protected override IDbAdapter DbAdapter => Respawn.DbAdapter.Postgres; - /// - /// Represents this member. - /// public override DbProviderFactory DbProviderFactory => NpgsqlFactory.Instance; - /// - /// Represents this member. - /// public override string ConnectionStringKey => ServiceNames.PostgresSqlServerServiceName; } -/// -/// Represents the RabbitMqTestContainer. -/// public sealed class RabbitMqTestContainer(IMessageSink messageSink) : TestContainerFixtureWithConnectionString(messageSink) { private readonly RabbitMqBuilder _builder = new RabbitMqBuilder("rabbitmq:4-management") .WithUsername("guest") .WithPassword("guest"); - /// - /// Executes this member. - /// protected override RabbitMqBuilder Configure() => _builder; - /// - /// Represents this member. - /// public override string ConnectionStringKey => ServiceNames.RabbitMqServiceName; - /// - /// Executes this member. - /// public override string ConnectionString => Container.GetConnectionString(); } diff --git a/samples/WebApi/WebApi.Tests/SideEffectIntegrationTests.cs b/samples/WebApi/WebApi.Tests/SideEffectIntegrationTests.cs index d8517c4..7ea3e9a 100644 --- a/samples/WebApi/WebApi.Tests/SideEffectIntegrationTests.cs +++ b/samples/WebApi/WebApi.Tests/SideEffectIntegrationTests.cs @@ -9,15 +9,9 @@ namespace WebApi.Tests; -/// -/// Represents the SideEffectIntegrationTests. -/// public sealed class SideEffectIntegrationTests(FixtureWrapper testFixture, ITestOutputHelper testOutputHelper) : BaseIntegrationTestCase(testFixture, testOutputHelper) { - /// - /// Executes this member. - /// [Fact] public async Task TestCreate() { diff --git a/samples/WebApi/WebApi/MainEntity/Create.cs b/samples/WebApi/WebApi/MainEntity/Create.cs index 7a5ea68..95a45cb 100644 --- a/samples/WebApi/WebApi/MainEntity/Create.cs +++ b/samples/WebApi/WebApi/MainEntity/Create.cs @@ -6,14 +6,8 @@ namespace WebApi.MainEntity; -/// -/// Represents the Create. -/// public static class Create { - /// - /// Represents the Request. - /// public sealed record Request(string Name); /// @@ -22,14 +16,8 @@ public sealed record Request(string Name); /// The id of the newly created MainEntity. public sealed record Response(Guid Id); - /// - /// Represents the Endpoint. - /// public class Endpoint : IEndpoint { - /// - /// Executes this member. - /// public void MapEndpoint(IEndpointRouteBuilder app) { app.MapPost("main-entities", async Task, ValidationProblem, NotFound, Conflict, ProblemHttpResult>> (ICommandHandler> handler, Request request) => diff --git a/samples/WebApi/WebApi/MainEntity/GetAll.cs b/samples/WebApi/WebApi/MainEntity/GetAll.cs index 165438b..d2b4ab8 100644 --- a/samples/WebApi/WebApi/MainEntity/GetAll.cs +++ b/samples/WebApi/WebApi/MainEntity/GetAll.cs @@ -5,22 +5,13 @@ namespace WebApi.MainEntity; -/// -/// Represents the GetAll. -/// public static class GetAll { public sealed record Response(IReadOnlyList MainEntities); - /// - /// Represents the Endpoint. - /// public class Endpoint : IEndpoint { - /// - /// Executes this member. - /// public void MapEndpoint(IEndpointRouteBuilder app) { app.MapGet("main-entities", async (ISender sender) => diff --git a/samples/WebApi/WebApi/MainEntity/GetById.cs b/samples/WebApi/WebApi/MainEntity/GetById.cs index 0bb2137..0ef73b7 100644 --- a/samples/WebApi/WebApi/MainEntity/GetById.cs +++ b/samples/WebApi/WebApi/MainEntity/GetById.cs @@ -5,19 +5,10 @@ namespace WebApi.MainEntity; -/// -/// Represents the GetById. -/// public static class GetById { - /// - /// Represents the Endpoint. - /// public class Endpoint : IEndpoint { - /// - /// Executes this member. - /// public void MapEndpoint(IEndpointRouteBuilder app) { app.MapGet("main-entities/{id:guid}", async (IQueryHandler> sender, Guid id) => diff --git a/src/Vulthil.Messaging.RabbitMq/ExchangeTypeMapper.cs b/src/Vulthil.Messaging.RabbitMq/ExchangeTypeMapper.cs index 0f4eed0..38a4ac4 100644 --- a/src/Vulthil.Messaging.RabbitMq/ExchangeTypeMapper.cs +++ b/src/Vulthil.Messaging.RabbitMq/ExchangeTypeMapper.cs @@ -8,8 +8,10 @@ internal static class ExchangeTypeMapper extension(MessagingExchangeType type) { /// - /// Executes this member. + /// Maps this to the corresponding RabbitMQ exchange type string, + /// falling back to for unrecognized values. /// + /// The RabbitMQ exchange type name. public string ToRabbitExchangeType() => type switch { MessagingExchangeType.Topic => ExchangeType.Topic, @@ -20,6 +22,12 @@ internal static class ExchangeTypeMapper }; } #else + /// + /// Maps the specified to the corresponding RabbitMQ exchange type string, + /// falling back to for unrecognized values. + /// + /// The messaging exchange type to map. + /// The RabbitMQ exchange type name. public static string ToRabbitExchangeType(this MessagingExchangeType type) => type switch { MessagingExchangeType.Topic => ExchangeType.Topic, diff --git a/src/Vulthil.Messaging.RabbitMq/Requests/MessageResult.cs b/src/Vulthil.Messaging.RabbitMq/Requests/MessageResult.cs index c3bfc85..ca4c227 100644 --- a/src/Vulthil.Messaging.RabbitMq/Requests/MessageResult.cs +++ b/src/Vulthil.Messaging.RabbitMq/Requests/MessageResult.cs @@ -2,26 +2,36 @@ namespace Vulthil.Messaging.RabbitMq.Requests; +/// +/// Wire-level envelope for an RPC reply: either a success carrying the serialized response payload, +/// or a failure carrying an error message. Written to the reply queue by the request handler and +/// read back by . +/// internal sealed record MessageResult { /// - /// Gets or sets this member value. + /// Gets a value indicating whether the request was handled successfully. When , + /// is non-null; otherwise is non-null. /// [MemberNotNullWhen(true, nameof(Value))] [MemberNotNullWhen(false, nameof(ErrorMessage))] public bool IsSuccess { get; private set; } /// - /// Gets or sets this member value. + /// Gets the serialized response payload, or when the request failed. /// public byte[]? Value { get; private set; } /// - /// Gets or sets this member value. + /// Gets the error description, or when the request succeeded. /// public string? ErrorMessage { get; private set; } /// - /// Executes this member. + /// Initializes a new , validating that a success carries a + /// and a failure carries an . /// + /// Whether the request was handled successfully. + /// The serialized response payload; required when is . + /// The error description; required when is . public MessageResult(bool isSuccess, byte[]? value, string? errorMessage = null) { if (isSuccess && value is null) @@ -39,11 +49,15 @@ public MessageResult(bool isSuccess, byte[]? value, string? errorMessage = null) } /// - /// Executes this member. + /// Creates a successful result wrapping the serialized response payload. /// + /// The serialized response payload. + /// A successful . public static MessageResult Success(byte[] value) => new(true, value, null); /// - /// Executes this member. + /// Creates a failed result carrying the specified error description. /// + /// The error description. + /// A failed . public static MessageResult Failure(string errorMessage) => new(false, null, errorMessage); } diff --git a/src/Vulthil.Messaging.RabbitMq/Requests/ResponseWaiter.cs b/src/Vulthil.Messaging.RabbitMq/Requests/ResponseWaiter.cs index 68776a4..6e453b7 100644 --- a/src/Vulthil.Messaging.RabbitMq/Requests/ResponseWaiter.cs +++ b/src/Vulthil.Messaging.RabbitMq/Requests/ResponseWaiter.cs @@ -8,8 +8,10 @@ internal sealed class ResponseWaiter( JsonSerializerOptions options) : IResponseWaiter where T : notnull { /// - /// Executes this member. + /// Completes the pending request by deserializing the reply body into a + /// and resolving the awaiting task with the typed success value or a failure error. /// + /// The raw reply payload received on the reply queue. public void Complete(ReadOnlySpan body) { try diff --git a/src/Vulthil.xUnit/BaseIntegrationTestCase.cs b/src/Vulthil.xUnit/BaseIntegrationTestCase.cs index 1926062..9c39ff0 100644 --- a/src/Vulthil.xUnit/BaseIntegrationTestCase.cs +++ b/src/Vulthil.xUnit/BaseIntegrationTestCase.cs @@ -97,8 +97,10 @@ public virtual async ValueTask DisposeAsync() } /// - /// Executes this member. + /// Disposes the current service scope, if one exists, so the next access to + /// resolves a fresh scope. /// + /// A task representing the asynchronous dispose operation. public async ValueTask ResetScope() { await (_scope?.DisposeAsync() ?? ValueTask.CompletedTask); diff --git a/tests/Vulthil.Messaging.RabbitMq.Tests/ExchangeTypeMapperTests.cs b/tests/Vulthil.Messaging.RabbitMq.Tests/ExchangeTypeMapperTests.cs index 2ceb188..e33960a 100644 --- a/tests/Vulthil.Messaging.RabbitMq.Tests/ExchangeTypeMapperTests.cs +++ b/tests/Vulthil.Messaging.RabbitMq.Tests/ExchangeTypeMapperTests.cs @@ -3,14 +3,8 @@ namespace Vulthil.Messaging.RabbitMq.Tests; -/// -/// Represents the ExchangeTypeMapperTests. -/// public sealed class ExchangeTypeMapperTests : BaseUnitTestCase { - /// - /// Executes this member. - /// [Fact] public void TopicExchangeShouldMapToTopicType() { @@ -21,9 +15,6 @@ public void TopicExchangeShouldMapToTopicType() result.ShouldBe("topic"); } - /// - /// Executes this member. - /// [Fact] public void DirectExchangeShouldMapToDirectType() { @@ -34,9 +25,6 @@ public void DirectExchangeShouldMapToDirectType() result.ShouldBe("direct"); } - /// - /// Executes this member. - /// [Fact] public void FanoutExchangeShouldMapToFanoutType() { @@ -47,9 +35,6 @@ public void FanoutExchangeShouldMapToFanoutType() result.ShouldBe("fanout"); } - /// - /// Executes this member. - /// [Fact] public void HeadersExchangeShouldMapToHeadersType() { @@ -60,9 +45,6 @@ public void HeadersExchangeShouldMapToHeadersType() result.ShouldBe("headers"); } - /// - /// Executes this member. - /// [Fact] public void InvalidExchangeShouldDefaultToTopic() { diff --git a/tests/Vulthil.Messaging.RabbitMq.Tests/MessageContextTests.cs b/tests/Vulthil.Messaging.RabbitMq.Tests/MessageContextTests.cs index 849c249..ba819ee 100644 --- a/tests/Vulthil.Messaging.RabbitMq.Tests/MessageContextTests.cs +++ b/tests/Vulthil.Messaging.RabbitMq.Tests/MessageContextTests.cs @@ -6,16 +6,10 @@ namespace Vulthil.Messaging.RabbitMq.Tests; -/// -/// Represents the MessageContextTests. -/// public sealed class MessageContextTests : BaseUnitTestCase { private sealed record TestMessage(string Content); - /// - /// Executes this member. - /// [Fact] public void CreateContextShouldMapPropertiesHeadersAndTiming() { @@ -59,9 +53,6 @@ public void CreateContextShouldMapPropertiesHeadersAndTiming() context.ExpirationTime.Value.ShouldBeLessThan(DateTimeOffset.UtcNow.AddSeconds(10)); } - /// - /// Executes this member. - /// [Fact] public void CreateContextShouldFallbackResponseAddressFromReplyTo() { @@ -75,9 +66,6 @@ public void CreateContextShouldFallbackResponseAddressFromReplyTo() context.ResponseAddress.ShouldBe(new Uri("queue:reply-queue")); } - /// - /// Executes this member. - /// [Fact] public void CreateContextShouldUseDefaultsWhenPropertiesAreMissing() { @@ -103,9 +91,6 @@ public void CreateContextShouldUseDefaultsWhenPropertiesAreMissing() context.ExpirationTime.ShouldBeNull(); } - /// - /// Executes this member. - /// [Fact] public void CreateContextGenericShouldIncludeTypedMessage() { diff --git a/tests/Vulthil.Messaging.RabbitMq.Tests/MessageTypeCacheTests.cs b/tests/Vulthil.Messaging.RabbitMq.Tests/MessageTypeCacheTests.cs index 25be4fc..4a6d431 100644 --- a/tests/Vulthil.Messaging.RabbitMq.Tests/MessageTypeCacheTests.cs +++ b/tests/Vulthil.Messaging.RabbitMq.Tests/MessageTypeCacheTests.cs @@ -9,9 +9,6 @@ namespace Vulthil.Messaging.RabbitMq.Tests; -/// -/// Represents the MessageTypeCacheTests. -/// public sealed class MessageTypeCacheTests : BaseUnitTestCase { private readonly Lazy _lazyTarget; diff --git a/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqConstantsTests.cs b/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqConstantsTests.cs index a4e786c..a7636d3 100644 --- a/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqConstantsTests.cs +++ b/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqConstantsTests.cs @@ -3,14 +3,8 @@ namespace Vulthil.Messaging.RabbitMq.Tests; -/// -/// Represents the RabbitMqConstantsTests. -/// public sealed class RabbitMqConstantsTests : BaseUnitTestCase { - /// - /// Executes this member. - /// [Fact] public void ContentTypeShouldBeApplicationJson() { @@ -18,9 +12,6 @@ public void ContentTypeShouldBeApplicationJson() RabbitMqConstants.ContentType.ShouldBe("application/json"); } - /// - /// Executes this member. - /// [Fact] public void GetMetadataShouldReturnPickerResultWhenTypeExists() { @@ -38,9 +29,6 @@ public void GetMetadataShouldReturnPickerResultWhenTypeExists() result.ShouldBe("test-value"); } - /// - /// Executes this member. - /// [Fact] public void GetMetadataShouldReturnNullWhenTypeNotFound() { @@ -55,9 +43,6 @@ public void GetMetadataShouldReturnNullWhenTypeNotFound() result.ShouldBeNull(); } - /// - /// Executes this member. - /// [Fact] public void GetMetadataShouldWalkInheritanceTree() { @@ -75,9 +60,6 @@ public void GetMetadataShouldWalkInheritanceTree() result.ShouldBe("base-value"); } - /// - /// Executes this member. - /// [Fact] public void GetMetadataShouldPreferDerivedTypeOverBase() { @@ -96,9 +78,6 @@ public void GetMetadataShouldPreferDerivedTypeOverBase() result.ShouldBe("derived-value"); } - /// - /// Executes this member. - /// [Fact] public void GetMetadataShouldReturnNullForObjectType() { @@ -112,9 +91,6 @@ public void GetMetadataShouldReturnNullForObjectType() result.ShouldBeNull(); } - /// - /// Executes this member. - /// [Fact] public void GetMetadataShouldHandleMultipleLevelsOfInheritance() { diff --git a/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqPublisherTests.cs b/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqPublisherTests.cs index 0fc8529..d482f8d 100644 --- a/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqPublisherTests.cs +++ b/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqPublisherTests.cs @@ -5,9 +5,6 @@ namespace Vulthil.Messaging.RabbitMq.Tests; -/// -/// Represents the RabbitMqPublisherTests. -/// public sealed class RabbitMqPublisherTests : BaseUnitTestCase { private readonly Lazy _lazyTarget; @@ -16,9 +13,6 @@ public sealed class RabbitMqPublisherTests : BaseUnitTestCase private RabbitMqPublisher Target => _lazyTarget.Value; - /// - /// Executes this member. - /// public RabbitMqPublisherTests() { var logger = GetMock>().Object; @@ -41,9 +35,6 @@ public RabbitMqPublisherTests() _lazyTarget = new(CreateInstance); } - /// - /// Executes this member. - /// [Fact] public async Task PublishAsyncWithValidMessagePublishesSuccessfully() { @@ -63,9 +54,6 @@ public async Task PublishAsyncWithValidMessagePublishesSuccessfully() CancellationToken), Times.Once); } - /// - /// Executes this member. - /// [Fact] public async Task PublishAsyncWithNullMessageThrowsArgumentNullException() { @@ -75,9 +63,6 @@ public async Task PublishAsyncWithNullMessageThrowsArgumentNullException() private sealed class TestMessage { - /// - /// Gets or sets this member value. - /// public string Content { get; set; } = string.Empty; } } diff --git a/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqSendEndpointProviderTests.cs b/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqSendEndpointProviderTests.cs index 4598559..cf396ef 100644 --- a/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqSendEndpointProviderTests.cs +++ b/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqSendEndpointProviderTests.cs @@ -5,9 +5,6 @@ namespace Vulthil.Messaging.RabbitMq.Tests; -/// -/// Represents the RabbitMqSendEndpointProviderTests. -/// public sealed class RabbitMqSendEndpointProviderTests : BaseUnitTestCase { private readonly Lazy _lazyTarget; diff --git a/tests/Vulthil.Messaging.Tests/MessagingConfiguratiorTests.cs b/tests/Vulthil.Messaging.Tests/MessagingConfiguratiorTests.cs index 2b9af39..d9beda9 100644 --- a/tests/Vulthil.Messaging.Tests/MessagingConfiguratiorTests.cs +++ b/tests/Vulthil.Messaging.Tests/MessagingConfiguratiorTests.cs @@ -5,9 +5,6 @@ namespace Vulthil.Messaging.Tests; -/// -/// Represents the MessagingConfiguratiorTests. -/// public sealed class MessagingConfiguratiorTests : BaseUnitTestCase { protected override HostApplicationBuilder CreateInstance() => Host.CreateApplicationBuilder(); @@ -45,9 +42,6 @@ public void AddMessagingShouldRegisterMessageConfigurationProvider() messageConfigurationProviderService.ShouldNotBeEmpty(); } - /// - /// Executes this member. - /// [Fact] public void AddMessagingQueueShouldRegisterQueueDefinition() { diff --git a/tests/Vulthil.Messaging.Tests/MessagingExchangeTypeTests.cs b/tests/Vulthil.Messaging.Tests/MessagingExchangeTypeTests.cs index 62b7b34..c8da2f3 100644 --- a/tests/Vulthil.Messaging.Tests/MessagingExchangeTypeTests.cs +++ b/tests/Vulthil.Messaging.Tests/MessagingExchangeTypeTests.cs @@ -2,14 +2,8 @@ namespace Vulthil.Messaging.Tests; -/// -/// Represents the MessagingExchangeTypeTests. -/// public sealed class MessagingExchangeTypeTests : BaseUnitTestCase { - /// - /// Executes this member. - /// [Fact] public void FanoutExchangeTypeShouldExist() { @@ -17,9 +11,6 @@ public void FanoutExchangeTypeShouldExist() MessagingExchangeType.Fanout.ShouldBe(MessagingExchangeType.Fanout); } - /// - /// Executes this member. - /// [Fact] public void DirectExchangeTypeShouldExist() { @@ -27,9 +18,6 @@ public void DirectExchangeTypeShouldExist() MessagingExchangeType.Direct.ShouldBe(MessagingExchangeType.Direct); } - /// - /// Executes this member. - /// [Fact] public void TopicExchangeTypeShouldExist() { @@ -37,9 +25,6 @@ public void TopicExchangeTypeShouldExist() MessagingExchangeType.Topic.ShouldBe(MessagingExchangeType.Topic); } - /// - /// Executes this member. - /// [Fact] public void HeadersExchangeTypeShouldExist() { @@ -47,9 +32,6 @@ public void HeadersExchangeTypeShouldExist() MessagingExchangeType.Headers.ShouldBe(MessagingExchangeType.Headers); } - /// - /// Executes this member. - /// [Fact] public void AllExchangeTypesAreUnique() { diff --git a/tests/Vulthil.Results.Tests/Errors/ErrorTests.cs b/tests/Vulthil.Results.Tests/Errors/ErrorTests.cs index 0ab2e48..27c7bf5 100644 --- a/tests/Vulthil.Results.Tests/Errors/ErrorTests.cs +++ b/tests/Vulthil.Results.Tests/Errors/ErrorTests.cs @@ -1,14 +1,8 @@ using Vulthil.xUnit; namespace Vulthil.Results.Tests.Errors; -/// -/// Represents the ErrorTests. -/// public sealed class ErrorTests : BaseUnitTestCase { - /// - /// Executes this member. - /// [Fact] public void ErrorNoneShouldBeEmptyFailure() { @@ -23,9 +17,6 @@ public void ErrorNoneShouldBeEmptyFailure() error.Type.ShouldBe(ErrorType.Failure); } - /// - /// Executes this member. - /// [Fact] public void ErrorNullValueShouldBeFailure() { @@ -40,9 +31,6 @@ public void ErrorNullValueShouldBeFailure() error.Type.ShouldBe(ErrorType.Failure); } - /// - /// Executes this member. - /// public static TheoryData ErrorTestData => new() { { Error.NotFound("C", "D"), ("C", "D"), ErrorType.NotFound }, @@ -52,18 +40,12 @@ public void ErrorNullValueShouldBeFailure() { new ValidationError([Error.NullValue]), ("Validation.General", "One or more validation errors occurred"), ErrorType.Validation }, }; - /// - /// Executes this member. - /// [Theory] [MemberData(nameof(ErrorTestData))] public void ErrorStaticFactoryMethodsShouldCreateErrors(Error error, (string Code, string Description) errorProperties, ErrorType expectedErrorType) => // Assert error.ShouldSatisfyAllConditions(e => e.Type.ShouldBe(expectedErrorType), e => e.Code.ShouldBe(errorProperties.Code), e => e.Description.ShouldBe(errorProperties.Description)); - /// - /// Executes this member. - /// [Fact] public void ValidationErrorFromResults() { diff --git a/tests/Vulthil.Results.Tests/Results/BindResultBaseTestCase.cs b/tests/Vulthil.Results.Tests/Results/BindResultBaseTestCase.cs index c0c6023..b177f47 100644 --- a/tests/Vulthil.Results.Tests/Results/BindResultBaseTestCase.cs +++ b/tests/Vulthil.Results.Tests/Results/BindResultBaseTestCase.cs @@ -2,335 +2,194 @@ namespace Vulthil.Results.Tests.Results; -/// -/// Represents the BindResultBaseTestCase. -/// public abstract class BindResultBaseTestCase : ResultBaseTestCase { - /// - /// Gets or sets this member value. - /// protected T1? Param { get; private set; } - /// - /// Executes this member. - /// protected Result Success() { FuncExecuted = true; return Result.Success(); } - /// - /// Executes this member. - /// protected Result SuccessT1(T1 _) { Param = _; return Success(); } - /// - /// Executes this member. - /// protected Result SuccessT1() { FuncExecuted = true; return Result.Success(T1.Value); } - /// - /// Executes this member. - /// protected Result SuccessT2() { FuncExecuted = true; return Result.Success(T2.Value); } - /// - /// Executes this member. - /// protected Result SuccessT1T2(T1 _) { Param = _; return SuccessT2(); } - /// - /// Executes this member. - /// protected Task TaskSuccess() { return Task.FromResult(Success()); } - /// - /// Executes this member. - /// protected Task TaskSuccessT1(T1 _) { return Task.FromResult(SuccessT1(_)); } - /// - /// Executes this member. - /// protected Task> TaskSuccessT1() { return Task.FromResult(SuccessT1()); } - /// - /// Executes this member. - /// protected Task> TaskSuccessT2() { return Task.FromResult(SuccessT2()); } - /// - /// Executes this member. - /// protected Task> TaskSuccessT1T2(T1 _) { return Task.FromResult(SuccessT1T2(_)); } - /// - /// Executes this member. - /// protected Result Failure() { FuncExecuted = false; return Result.Failure(NullError); } - /// - /// Executes this member. - /// protected Result FailureT1() { FuncExecuted = false; return Result.Failure(NullError); } - /// - /// Executes this member. - /// protected Result FailureT1(T1 _) { Param = _; return Failure(); } - /// - /// Executes this member. - /// protected Result FailureT2() { FuncExecuted = false; return Result.Failure(NullError); } - /// - /// Executes this member. - /// protected Result FailureT1T2(T1 _) { Param = _; return FailureT2(); } - /// - /// Executes this member. - /// protected Task TaskFailure() { return Task.FromResult(Failure()); } - /// - /// Executes this member. - /// protected Task> TaskFailureT1() { return Task.FromResult(FailureT1()); } - /// - /// Executes this member. - /// protected Task> TaskFailureT2() { return Task.FromResult(FailureT2()); } - /// - /// Executes this member. - /// protected Task> TaskFailureT1T2(T1 _) { return Task.FromResult(FailureT1T2(_)); } - /// - /// Executes this member. - /// protected void AssertSuccess(Result output) => BaseAssertSuccess(output); - /// - /// Executes this member. - /// protected void AssertFailure(Result output) => BaseAssertFailure(output); } -/// -/// Represents the MatchResultBaseTestCase. -/// public abstract class MatchResultBaseTestCase : ResultBaseTestCase { - /// - /// Gets or sets this member value. - /// protected bool SuccessExecuted { get; private set; } - /// - /// Gets or sets this member value. - /// protected bool FailureExecuted { get; private set; } - /// - /// Gets or sets this member value. - /// protected T1? Param { get; private set; } - /// - /// Gets or sets this member value. - /// protected Error? Error { get; private set; } - /// - /// Executes this member. - /// protected void AssertSuccess() { SuccessExecuted.ShouldBeTrue(); } - /// - /// Executes this member. - /// protected void AssertSuccess(Result output) { AssertSuccess(); BaseAssertSuccess(output); } - /// - /// Executes this member. - /// protected void AssertSuccessT1() { AssertSuccess(); Param.ShouldBe(T1.Value); } - /// - /// Executes this member. - /// protected void AssertSuccessT2(T2 output) { AssertSuccess(); output.ShouldBe(T2.Value); } - /// - /// Executes this member. - /// protected void AssertSuccessT1T2(T2 output) { AssertSuccessT1(); AssertSuccessT2(output); } - /// - /// Executes this member. - /// protected void AssertFailure() { FailureExecuted.ShouldBeTrue(); Error.ShouldBe(NullError); Param.ShouldBeNull(); } - /// - /// Executes this member. - /// protected void AssertFailureT2(T2 output) { AssertFailure(); output.ShouldBe(T2.Value2); } - /// - /// Executes this member. - /// protected void OnSuccess() { FuncExecuted = true; SuccessExecuted = true; } - /// - /// Executes this member. - /// protected void OnSuccessT1(T1 _) { OnSuccess(); Param = _; } - /// - /// Executes this member. - /// protected T2 OnSuccessT2() { OnSuccess(); return T2.Value; } - /// - /// Executes this member. - /// protected T2 OnSuccessT1T2(T1 _) { OnSuccessT1(_); return OnSuccessT2(); } - /// - /// Executes this member. - /// protected Task OnSuccessTask() { OnSuccess(); return Task.CompletedTask; } - /// - /// Executes this member. - /// protected Task OnSuccessTaskT1(T1 _) { OnSuccessT1(_); return Task.CompletedTask; } - /// - /// Executes this member. - /// protected Task OnSuccessTaskT2() => Task.FromResult(OnSuccessT2()); - /// - /// Executes this member. - /// protected Task OnSuccessTaskT1T2(T1 _) => Task.FromResult(OnSuccessT1T2(_)); - /// - /// Executes this member. - /// protected void OnFailure(Error _) { FailureExecuted = true; Error = _; } - /// - /// Executes this member. - /// protected T2 OnFailureT2(Error _) { OnFailure(_); return T2.Value2; } - /// - /// Executes this member. - /// protected Task OnFailureTask(Error _) { OnFailure(_); return Task.CompletedTask; } - /// - /// Executes this member. - /// protected Task OnFailureTaskT2(Error _) => Task.FromResult(OnFailureT2(_)); } diff --git a/tests/Vulthil.Results.Tests/Results/BindResultExtensionsTests.cs b/tests/Vulthil.Results.Tests/Results/BindResultExtensionsTests.cs index 7d533f9..65e6476 100644 --- a/tests/Vulthil.Results.Tests/Results/BindResultExtensionsTests.cs +++ b/tests/Vulthil.Results.Tests/Results/BindResultExtensionsTests.cs @@ -1,13 +1,7 @@ namespace Vulthil.Results.Tests.Results; -/// -/// Represents the BindResultExtensionsTests. -/// public sealed class BindResultExtensionsTests : BindResultBaseTestCase { - /// - /// Executes this member. - /// [Fact] public void BindResultSuccess() { @@ -21,9 +15,6 @@ public void BindResultSuccess() AssertSuccess(result2); } - /// - /// Executes this member. - /// [Fact] public void BindResultFailure() { @@ -37,9 +28,6 @@ public void BindResultFailure() AssertFailure(result2); } - /// - /// Executes this member. - /// [Fact] public void BindResultSuccessT1() { @@ -54,9 +42,6 @@ public void BindResultSuccessT1() Param.ShouldBe(result.Value); } - /// - /// Executes this member. - /// [Fact] public void BindResultFailureT1() { @@ -71,9 +56,6 @@ public void BindResultFailureT1() Param.ShouldBeNull(); } - /// - /// Executes this member. - /// [Fact] public void BindResultSuccessT2() { @@ -88,9 +70,6 @@ public void BindResultSuccessT2() result2.Value.ShouldBe(T2.Value); } - /// - /// Executes this member. - /// [Fact] public void BindResultFailureT2() { @@ -104,9 +83,6 @@ public void BindResultFailureT2() AssertFailure(result2); } - /// - /// Executes this member. - /// [Fact] public void BindResultSuccessT1T2() { @@ -122,9 +98,6 @@ public void BindResultSuccessT1T2() result2.Value.ShouldBe(T2.Value); } - /// - /// Executes this member. - /// [Fact] public void BindResultFailureT1T2() { @@ -139,9 +112,6 @@ public void BindResultFailureT1T2() Param.ShouldBeNull(); } - /// - /// Executes this member. - /// [Fact] public async Task BindAsyncResult() { @@ -155,9 +125,6 @@ public async Task BindAsyncResult() AssertSuccess(await task); } - /// - /// Executes this member. - /// [Fact] public async Task BindAsyncResultLeft() { @@ -171,9 +138,6 @@ public async Task BindAsyncResultLeft() AssertSuccess(await task); } - /// - /// Executes this member. - /// [Fact] public async Task BindAsyncResultRight() { @@ -187,9 +151,6 @@ public async Task BindAsyncResultRight() AssertSuccess(await task); } - /// - /// Executes this member. - /// [Fact] public async Task BindAsyncResultFailure() { @@ -203,9 +164,6 @@ public async Task BindAsyncResultFailure() AssertFailure(await task); } - /// - /// Executes this member. - /// [Fact] public async Task BindAsyncResultFailureLeft() { @@ -219,9 +177,6 @@ public async Task BindAsyncResultFailureLeft() AssertFailure(await task); } - /// - /// Executes this member. - /// [Fact] public async Task BindAsyncResultFailureRight() { @@ -235,9 +190,6 @@ public async Task BindAsyncResultFailureRight() AssertFailure(await task); } - /// - /// Executes this member. - /// [Fact] public async Task BindAsyncResultSuccessT1() { @@ -252,9 +204,6 @@ public async Task BindAsyncResultSuccessT1() Param.ShouldBe(T1.Value); } - /// - /// Executes this member. - /// [Fact] public async Task BindAsyncResultSuccessT1Left() { @@ -269,9 +218,6 @@ public async Task BindAsyncResultSuccessT1Left() Param.ShouldBe(T1.Value); } - /// - /// Executes this member. - /// [Fact] public async Task BindAsyncResultSuccessT1Right() { @@ -286,9 +232,6 @@ public async Task BindAsyncResultSuccessT1Right() Param.ShouldBe(T1.Value); } - /// - /// Executes this member. - /// [Fact] public async Task BindAsyncResultFailureT1() { @@ -303,9 +246,6 @@ public async Task BindAsyncResultFailureT1() Param.ShouldBeNull(); } - /// - /// Executes this member. - /// [Fact] public async Task BindAsyncResultFailureT1Left() { @@ -320,9 +260,6 @@ public async Task BindAsyncResultFailureT1Left() Param.ShouldBeNull(); } - /// - /// Executes this member. - /// [Fact] public async Task BindAsyncResultFailureT1Right() { @@ -337,9 +274,6 @@ public async Task BindAsyncResultFailureT1Right() Param.ShouldBeNull(); } - /// - /// Executes this member. - /// [Fact] public async Task BindAsyncResultSuccessT2() { @@ -353,9 +287,6 @@ public async Task BindAsyncResultSuccessT2() AssertSuccess(await task); } - /// - /// Executes this member. - /// [Fact] public async Task BindAsyncResultSuccessT2Left() { @@ -369,9 +300,6 @@ public async Task BindAsyncResultSuccessT2Left() AssertSuccess(await task); } - /// - /// Executes this member. - /// [Fact] public async Task BindAsyncResultSuccessT2Right() { @@ -385,9 +313,6 @@ public async Task BindAsyncResultSuccessT2Right() AssertSuccess(await task); } - /// - /// Executes this member. - /// [Fact] public async Task BindAsyncResultFailureT2() { @@ -402,9 +327,6 @@ public async Task BindAsyncResultFailureT2() Param.ShouldBeNull(); } - /// - /// Executes this member. - /// [Fact] public async Task BindAsyncResultFailureT2Left() { @@ -419,9 +341,6 @@ public async Task BindAsyncResultFailureT2Left() Param.ShouldBeNull(); } - /// - /// Executes this member. - /// [Fact] public async Task BindAsyncResultFailureT2Right() { @@ -436,9 +355,6 @@ public async Task BindAsyncResultFailureT2Right() Param.ShouldBeNull(); } - /// - /// Executes this member. - /// [Fact] public async Task BindAsyncResultSuccessT1T2() { @@ -453,9 +369,6 @@ public async Task BindAsyncResultSuccessT1T2() Param.ShouldBe(T1.Value); } - /// - /// Executes this member. - /// [Fact] public async Task BindAsyncResultSuccessT1T2Left() { @@ -470,9 +383,6 @@ public async Task BindAsyncResultSuccessT1T2Left() Param.ShouldBe(T1.Value); } - /// - /// Executes this member. - /// [Fact] public async Task BindAsyncResultSuccessT1T2Right() { @@ -487,9 +397,6 @@ public async Task BindAsyncResultSuccessT1T2Right() Param.ShouldBe(T1.Value); } - /// - /// Executes this member. - /// [Fact] public async Task BindAsyncResultFailureT1T2() { @@ -504,9 +411,6 @@ public async Task BindAsyncResultFailureT1T2() Param.ShouldBeNull(); } - /// - /// Executes this member. - /// [Fact] public async Task BindAsyncResultFailureT1T2Left() { @@ -521,9 +425,6 @@ public async Task BindAsyncResultFailureT1T2Left() Param.ShouldBeNull(); } - /// - /// Executes this member. - /// [Fact] public async Task BindAsyncResultFailureT1T2Right() { diff --git a/tests/Vulthil.Results.Tests/Results/MapResultBaseTestCase.cs b/tests/Vulthil.Results.Tests/Results/MapResultBaseTestCase.cs index 22a1f60..2658ed9 100644 --- a/tests/Vulthil.Results.Tests/Results/MapResultBaseTestCase.cs +++ b/tests/Vulthil.Results.Tests/Results/MapResultBaseTestCase.cs @@ -2,65 +2,38 @@ namespace Vulthil.Results.Tests.Results; -/// -/// Represents the MapResultBaseTestCase. -/// public abstract class MapResultBaseTestCase : ResultBaseTestCase { - /// - /// Executes this member. - /// protected T1 FuncT1() { FuncExecuted = true; return T1.Value; } - /// - /// Executes this member. - /// protected T2 FuncT2() { FuncExecuted = true; return T2.Value; } - /// - /// Executes this member. - /// protected Task TaskFuncT2() { FuncExecuted = true; return Task.FromResult(T2.Value); } - /// - /// Executes this member. - /// protected T2 FuncT1T2(T1 _) { FuncExecuted = true; return T2.Value; } - /// - /// Executes this member. - /// protected Task TaskFuncT1T2(T1 _) { FuncExecuted = true; return Task.FromResult(T2.Value); } - /// - /// Executes this member. - /// protected void AssertSuccess(Result output) => BaseAssertSuccess(output); - /// - /// Executes this member. - /// protected void AssertSuccess(Result output) => BaseAssertSuccess(T2.Value, output); - /// - /// Executes this member. - /// protected void AssertFailure(Result output) => BaseAssertFailure(output); } diff --git a/tests/Vulthil.Results.Tests/Results/MapResultExtensionsTests.cs b/tests/Vulthil.Results.Tests/Results/MapResultExtensionsTests.cs index 8a9a5c3..4289eb3 100644 --- a/tests/Vulthil.Results.Tests/Results/MapResultExtensionsTests.cs +++ b/tests/Vulthil.Results.Tests/Results/MapResultExtensionsTests.cs @@ -1,13 +1,7 @@ namespace Vulthil.Results.Tests.Results; -/// -/// Represents the MapResultExtensionsTests. -/// public sealed class MapResultExtensionsTests : MapResultBaseTestCase { - /// - /// Executes this member. - /// [Fact] public void MapResultSuccess() { @@ -21,9 +15,6 @@ public void MapResultSuccess() AssertSuccess(result2); } - /// - /// Executes this member. - /// [Fact] public void MapResultSuccessT1T2() { @@ -37,9 +28,6 @@ public void MapResultSuccessT1T2() AssertSuccess(result2); } - /// - /// Executes this member. - /// [Fact] public void MapResultFailure() { @@ -53,9 +41,6 @@ public void MapResultFailure() AssertFailure(result2); } - /// - /// Executes this member. - /// [Fact] public void MapResultFailureT1T2() { @@ -69,9 +54,6 @@ public void MapResultFailureT1T2() AssertFailure(result2); } - /// - /// Executes this member. - /// [Fact] public async Task MapAsyncResult() { @@ -85,9 +67,6 @@ public async Task MapAsyncResult() AssertSuccess(await task); } - /// - /// Executes this member. - /// [Fact] public async Task MapAsyncResultLeft() { @@ -101,9 +80,6 @@ public async Task MapAsyncResultLeft() AssertSuccess(await task); } - /// - /// Executes this member. - /// [Fact] public async Task MapAsyncResultRight() { @@ -117,9 +93,6 @@ public async Task MapAsyncResultRight() AssertSuccess(await task); } - /// - /// Executes this member. - /// [Fact] public async Task MapAsyncResultFailure() { @@ -133,9 +106,6 @@ public async Task MapAsyncResultFailure() AssertFailure(await task); } - /// - /// Executes this member. - /// [Fact] public async Task MapAsyncResultFailureLeft() { @@ -149,9 +119,6 @@ public async Task MapAsyncResultFailureLeft() AssertFailure(await task); } - /// - /// Executes this member. - /// [Fact] public async Task MapAsyncResultFailureRight() { @@ -165,9 +132,6 @@ public async Task MapAsyncResultFailureRight() AssertFailure(await task); } - /// - /// Executes this member. - /// [Fact] public async Task MapAsyncResultSuccessT1T2() { @@ -181,9 +145,6 @@ public async Task MapAsyncResultSuccessT1T2() AssertSuccess(await task); } - /// - /// Executes this member. - /// [Fact] public async Task MapAsyncResultSuccessT1T2Left() { @@ -197,9 +158,6 @@ public async Task MapAsyncResultSuccessT1T2Left() AssertSuccess(await task); } - /// - /// Executes this member. - /// [Fact] public async Task MapAsyncResultSuccessT1T2Right() { @@ -213,9 +171,6 @@ public async Task MapAsyncResultSuccessT1T2Right() AssertSuccess(await task); } - /// - /// Executes this member. - /// [Fact] public async Task MapAsyncResultFailureT1T2() { @@ -229,9 +184,6 @@ public async Task MapAsyncResultFailureT1T2() AssertFailure(await task); } - /// - /// Executes this member. - /// [Fact] public async Task MapAsyncResultFailureT1T2Left() { @@ -245,9 +197,6 @@ public async Task MapAsyncResultFailureT1T2Left() AssertFailure(await task); } - /// - /// Executes this member. - /// [Fact] public async Task MapAsyncResultFailureT1T2Right() { diff --git a/tests/Vulthil.Results.Tests/Results/MatchResultExtensionsTests.cs b/tests/Vulthil.Results.Tests/Results/MatchResultExtensionsTests.cs index bb88f49..ae610ab 100644 --- a/tests/Vulthil.Results.Tests/Results/MatchResultExtensionsTests.cs +++ b/tests/Vulthil.Results.Tests/Results/MatchResultExtensionsTests.cs @@ -1,13 +1,7 @@ namespace Vulthil.Results.Tests.Results; -/// -/// Represents the MatchResultExtensionsTests. -/// public sealed class MatchResultExtensionsTests : MatchResultBaseTestCase { - /// - /// Executes this member. - /// [Fact] public void MatchResultSuccess() { @@ -21,9 +15,6 @@ public void MatchResultSuccess() AssertSuccess(); } - /// - /// Executes this member. - /// [Fact] public void MatchResultFailure() { @@ -37,9 +28,6 @@ public void MatchResultFailure() AssertFailure(); } - /// - /// Executes this member. - /// [Fact] public void MatchResultSuccessT1() { @@ -53,9 +41,6 @@ public void MatchResultSuccessT1() AssertSuccessT1(); } - /// - /// Executes this member. - /// [Fact] public void MatchResultFailureT1() { @@ -69,9 +54,6 @@ public void MatchResultFailureT1() AssertFailure(); } - /// - /// Executes this member. - /// [Fact] public void MatchResultSuccessT2() { @@ -85,9 +67,6 @@ public void MatchResultSuccessT2() AssertSuccessT2(t2); } - /// - /// Executes this member. - /// [Fact] public void MatchResultFailureT2() { @@ -101,9 +80,6 @@ public void MatchResultFailureT2() AssertFailureT2(t2); } - /// - /// Executes this member. - /// [Fact] public void MatchResultSuccessT1T2() { @@ -117,9 +93,6 @@ public void MatchResultSuccessT1T2() AssertSuccessT1T2(t2); } - /// - /// Executes this member. - /// [Fact] public void MatchResultFailureT1T2() { @@ -134,9 +107,6 @@ public void MatchResultFailureT1T2() AssertFailureT2(t2); } - /// - /// Executes this member. - /// [Fact] public async Task MatchAsyncResult() { @@ -150,9 +120,6 @@ public async Task MatchAsyncResult() AssertSuccess(); } - /// - /// Executes this member. - /// [Fact] public async Task MatchAsyncResultLeft() { @@ -166,9 +133,6 @@ public async Task MatchAsyncResultLeft() AssertSuccess(); } - /// - /// Executes this member. - /// [Fact] public async Task MatchAsyncResultRight() { @@ -182,9 +146,6 @@ public async Task MatchAsyncResultRight() AssertSuccess(); } - /// - /// Executes this member. - /// [Fact] public async Task MatchAsyncResultTask1() { @@ -198,9 +159,6 @@ public async Task MatchAsyncResultTask1() AssertSuccess(); } - /// - /// Executes this member. - /// [Fact] public async Task MatchAsyncResultTask2() { @@ -214,9 +172,6 @@ public async Task MatchAsyncResultTask2() AssertSuccess(); } - /// - /// Executes this member. - /// [Fact] public async Task MatchAsyncResultTaskLeft() { @@ -230,9 +185,6 @@ public async Task MatchAsyncResultTaskLeft() AssertSuccess(); } - /// - /// Executes this member. - /// [Fact] public async Task MatchAsyncResultTaskRight() { @@ -246,9 +198,6 @@ public async Task MatchAsyncResultTaskRight() AssertSuccess(); } - /// - /// Executes this member. - /// [Fact] public async Task MatchAsyncResultFailure() { @@ -262,9 +211,6 @@ public async Task MatchAsyncResultFailure() AssertFailure(); } - /// - /// Executes this member. - /// [Fact] public async Task MatchAsyncResultFailureLeft() { @@ -278,9 +224,6 @@ public async Task MatchAsyncResultFailureLeft() AssertFailure(); } - /// - /// Executes this member. - /// [Fact] public async Task MatchAsyncResultFailureRight() { @@ -294,9 +237,6 @@ public async Task MatchAsyncResultFailureRight() AssertFailure(); } - /// - /// Executes this member. - /// [Fact] public async Task MatchAsyncResultTaskFailure1() { @@ -310,9 +250,6 @@ public async Task MatchAsyncResultTaskFailure1() AssertFailure(); } - /// - /// Executes this member. - /// [Fact] public async Task MatchAsyncResultTaskFailure2() { @@ -326,9 +263,6 @@ public async Task MatchAsyncResultTaskFailure2() AssertFailure(); } - /// - /// Executes this member. - /// [Fact] public async Task MatchAsyncResultTaskFailureLeft() { @@ -342,9 +276,6 @@ public async Task MatchAsyncResultTaskFailureLeft() AssertFailure(); } - /// - /// Executes this member. - /// [Fact] public async Task MatchAsyncResultTaskFailureRight() { @@ -358,9 +289,6 @@ public async Task MatchAsyncResultTaskFailureRight() AssertFailure(); } - /// - /// Executes this member. - /// [Fact] public async Task MatchAsyncResultT1() { @@ -374,9 +302,6 @@ public async Task MatchAsyncResultT1() AssertSuccessT1(); } - /// - /// Executes this member. - /// [Fact] public async Task MatchAsyncResultT1Left() { @@ -390,9 +315,6 @@ public async Task MatchAsyncResultT1Left() AssertSuccessT1(); } - /// - /// Executes this member. - /// [Fact] public async Task MatchAsyncResultT1Right() { @@ -406,9 +328,6 @@ public async Task MatchAsyncResultT1Right() AssertSuccessT1(); } - /// - /// Executes this member. - /// [Fact] public async Task MatchAsyncResultTaskT11() { @@ -422,9 +341,6 @@ public async Task MatchAsyncResultTaskT11() AssertSuccessT1(); } - /// - /// Executes this member. - /// [Fact] public async Task MatchAsyncResultTaskT12() { @@ -438,9 +354,6 @@ public async Task MatchAsyncResultTaskT12() AssertSuccessT1(); } - /// - /// Executes this member. - /// [Fact] public async Task MatchAsyncResultTaskT1Left() { @@ -454,9 +367,6 @@ public async Task MatchAsyncResultTaskT1Left() AssertSuccessT1(); } - /// - /// Executes this member. - /// [Fact] public async Task MatchAsyncResultTaskT1Right() { @@ -470,9 +380,6 @@ public async Task MatchAsyncResultTaskT1Right() AssertSuccessT1(); } - /// - /// Executes this member. - /// [Fact] public async Task MatchAsyncResultFailureT1() { @@ -486,9 +393,6 @@ public async Task MatchAsyncResultFailureT1() AssertFailure(); } - /// - /// Executes this member. - /// [Fact] public async Task MatchAsyncResultFailureT1Left() { @@ -502,9 +406,6 @@ public async Task MatchAsyncResultFailureT1Left() AssertFailure(); } - /// - /// Executes this member. - /// [Fact] public async Task MatchAsyncResultFailureT1Right() { @@ -518,9 +419,6 @@ public async Task MatchAsyncResultFailureT1Right() AssertFailure(); } - /// - /// Executes this member. - /// [Fact] public async Task MatchAsyncResultTaskFailureT11() { @@ -534,9 +432,6 @@ public async Task MatchAsyncResultTaskFailureT11() AssertFailure(); } - /// - /// Executes this member. - /// [Fact] public async Task MatchAsyncResultTaskFailureT12() { @@ -550,9 +445,6 @@ public async Task MatchAsyncResultTaskFailureT12() AssertFailure(); } - /// - /// Executes this member. - /// [Fact] public async Task MatchAsyncResultTaskFailureT1Left() { @@ -566,9 +458,6 @@ public async Task MatchAsyncResultTaskFailureT1Left() AssertFailure(); } - /// - /// Executes this member. - /// [Fact] public async Task MatchAsyncResultTaskFailureT1Right() { @@ -582,9 +471,6 @@ public async Task MatchAsyncResultTaskFailureT1Right() AssertFailure(); } - /// - /// Executes this member. - /// [Fact] public async Task MatchAsyncResultT2() { @@ -598,9 +484,6 @@ public async Task MatchAsyncResultT2() AssertSuccessT2(t2); } - /// - /// Executes this member. - /// [Fact] public async Task MatchAsyncResultT2Left() { @@ -614,9 +497,6 @@ public async Task MatchAsyncResultT2Left() AssertSuccessT2(t2); } - /// - /// Executes this member. - /// [Fact] public async Task MatchAsyncResultT2Right() { @@ -630,9 +510,6 @@ public async Task MatchAsyncResultT2Right() AssertSuccessT2(t2); } - /// - /// Executes this member. - /// [Fact] public async Task MatchAsyncResultTaskT21() { @@ -646,9 +523,6 @@ public async Task MatchAsyncResultTaskT21() AssertSuccessT2(t2); } - /// - /// Executes this member. - /// [Fact] public async Task MatchAsyncResultTaskT22() { @@ -662,9 +536,6 @@ public async Task MatchAsyncResultTaskT22() AssertSuccessT2(t2); } - /// - /// Executes this member. - /// [Fact] public async Task MatchAsyncResultTaskT2Left() { @@ -678,9 +549,6 @@ public async Task MatchAsyncResultTaskT2Left() AssertSuccessT2(t2); } - /// - /// Executes this member. - /// [Fact] public async Task MatchAsyncResultTaskT2Right() { @@ -694,9 +562,6 @@ public async Task MatchAsyncResultTaskT2Right() AssertSuccessT2(t2); } - /// - /// Executes this member. - /// [Fact] public async Task MatchAsyncResultFailureT2() { @@ -710,9 +575,6 @@ public async Task MatchAsyncResultFailureT2() AssertFailureT2(t2); } - /// - /// Executes this member. - /// [Fact] public async Task MatchAsyncResultFailureT2Left() { @@ -726,9 +588,6 @@ public async Task MatchAsyncResultFailureT2Left() AssertFailureT2(t2); } - /// - /// Executes this member. - /// [Fact] public async Task MatchAsyncResultFailureT2Right() { @@ -742,9 +601,6 @@ public async Task MatchAsyncResultFailureT2Right() AssertFailureT2(t2); } - /// - /// Executes this member. - /// [Fact] public async Task MatchAsyncResultTaskFailureT21() { @@ -758,9 +614,6 @@ public async Task MatchAsyncResultTaskFailureT21() AssertFailureT2(t2); } - /// - /// Executes this member. - /// [Fact] public async Task MatchAsyncResultTaskFailureT22() { @@ -774,9 +627,6 @@ public async Task MatchAsyncResultTaskFailureT22() AssertFailureT2(t2); } - /// - /// Executes this member. - /// [Fact] public async Task MatchAsyncResultTaskFailureT2Left() { @@ -790,9 +640,6 @@ public async Task MatchAsyncResultTaskFailureT2Left() AssertFailureT2(t2); } - /// - /// Executes this member. - /// [Fact] public async Task MatchAsyncResultTaskFailureT2Right() { @@ -806,9 +653,6 @@ public async Task MatchAsyncResultTaskFailureT2Right() AssertFailureT2(t2); } - /// - /// Executes this member. - /// [Fact] public async Task MatchAsyncResultT1T2() { @@ -822,9 +666,6 @@ public async Task MatchAsyncResultT1T2() AssertSuccessT2(t2); } - /// - /// Executes this member. - /// [Fact] public async Task MatchAsyncResultT1T2Left() { @@ -838,9 +679,6 @@ public async Task MatchAsyncResultT1T2Left() AssertSuccessT1T2(t2); } - /// - /// Executes this member. - /// [Fact] public async Task MatchAsyncResultT1T2Right() { @@ -854,9 +692,6 @@ public async Task MatchAsyncResultT1T2Right() AssertSuccessT1T2(t2); } - /// - /// Executes this member. - /// [Fact] public async Task MatchAsyncResultTaskT1T21() { @@ -870,9 +705,6 @@ public async Task MatchAsyncResultTaskT1T21() AssertSuccessT1T2(t2); } - /// - /// Executes this member. - /// [Fact] public async Task MatchAsyncResultTaskT1T22() { @@ -886,9 +718,6 @@ public async Task MatchAsyncResultTaskT1T22() AssertSuccessT1T2(t2); } - /// - /// Executes this member. - /// [Fact] public async Task MatchAsyncResultTaskT1T2Left() { @@ -902,9 +731,6 @@ public async Task MatchAsyncResultTaskT1T2Left() AssertSuccessT1T2(t2); } - /// - /// Executes this member. - /// [Fact] public async Task MatchAsyncResultTaskT1T2Right() { @@ -918,9 +744,6 @@ public async Task MatchAsyncResultTaskT1T2Right() AssertSuccessT1T2(t2); } - /// - /// Executes this member. - /// [Fact] public async Task MatchAsyncResultFailureT1T2() { @@ -934,9 +757,6 @@ public async Task MatchAsyncResultFailureT1T2() AssertFailureT2(t2); } - /// - /// Executes this member. - /// [Fact] public async Task MatchAsyncResultFailureT1T2Left() { @@ -950,9 +770,6 @@ public async Task MatchAsyncResultFailureT1T2Left() AssertFailureT2(t2); } - /// - /// Executes this member. - /// [Fact] public async Task MatchAsyncResultFailureT1T2Right() { @@ -966,9 +783,6 @@ public async Task MatchAsyncResultFailureT1T2Right() AssertFailureT2(t2); } - /// - /// Executes this member. - /// [Fact] public async Task MatchAsyncResultTaskFailureT1T21() { @@ -982,9 +796,6 @@ public async Task MatchAsyncResultTaskFailureT1T21() AssertFailureT2(t2); } - /// - /// Executes this member. - /// [Fact] public async Task MatchAsyncResultTaskFailureT1T22() { @@ -998,9 +809,6 @@ public async Task MatchAsyncResultTaskFailureT1T22() AssertFailureT2(t2); } - /// - /// Executes this member. - /// [Fact] public async Task MatchAsyncResultTaskFailureT1T2Left() { @@ -1014,9 +822,6 @@ public async Task MatchAsyncResultTaskFailureT1T2Left() AssertFailureT2(t2); } - /// - /// Executes this member. - /// [Fact] public async Task MatchAsyncResultTaskFailureT1T2Right() { diff --git a/tests/Vulthil.Results.Tests/Results/ResultBaseTestCase.cs b/tests/Vulthil.Results.Tests/Results/ResultBaseTestCase.cs index a66d1c2..da4bf92 100644 --- a/tests/Vulthil.Results.Tests/Results/ResultBaseTestCase.cs +++ b/tests/Vulthil.Results.Tests/Results/ResultBaseTestCase.cs @@ -4,175 +4,67 @@ namespace Vulthil.Results.Tests.Results; -/// -/// Represents the ResultBaseTestCase. -/// public abstract class ResultBaseTestCase : BaseUnitTestCase { - /// - /// Gets or sets this member value. - /// protected bool FuncExecuted { get; set; } - /// - /// Gets or sets this member value. - /// protected static Error NullError { get; } = Error.NullValue; - /// - /// Represents the T1. - /// protected sealed class T1 { - /// - /// Executes this member. - /// public static readonly T1 Value = new(); - /// - /// Executes this member. - /// public static readonly T1 Value2 = new(); } - /// - /// Represents the T2. - /// protected sealed class T2 { - /// - /// Executes this member. - /// public static readonly T2 Value = new(); - /// - /// Executes this member. - /// public static readonly T2 Value2 = new(); } - /// - /// Executes this member. - /// [DoesNotReturn] protected static void Fail() => Assert.Fail("Should not be called"); - /// - /// Executes this member. - /// protected static void Fail(Error _) => Fail(); - /// - /// Executes this member. - /// protected static Task FailTask() { Fail(); return default; } - /// - /// Executes this member. - /// protected static Task FailTask(Error _) => FailTask(); - /// - /// Executes this member. - /// protected static Result FailResult() { Fail(); return default; } - /// - /// Executes this member. - /// protected static Result FailResult(Error _) => FailResult(); - /// - /// Executes this member. - /// protected static Task FailResultTask() => Task.FromResult(FailResult()); - /// - /// Executes this member. - /// protected static Task FailResultTask(Error _) => FailResultTask(); - /// - /// Executes this member. - /// protected static void FailT1(T1 _) => Fail(); - /// - /// Executes this member. - /// protected static Task FailTaskT1(T1 _) { Fail(); return default; } - /// - /// Executes this member. - /// protected static Result FailResultT1(T1 _) { Fail(); return default; } - /// - /// Executes this member. - /// protected static Task FailResultTaskT1(T1 _) => Task.FromResult(FailResultT1(_)); - /// - /// Executes this member. - /// protected static T2 FailT2() { Fail(); return default; } - /// - /// Executes this member. - /// protected static T2 FailT2(Error _) => FailT2(); - /// - /// Executes this member. - /// protected static Task FailTaskT2() => Task.FromResult(FailT2()); - /// - /// Executes this member. - /// protected static Task FailTaskT2(Error _) => FailTaskT2(); - /// - /// Executes this member. - /// protected static Result FailResultT2() => Result.Success(FailT2()); - /// - /// Executes this member. - /// protected static Result FailResultT2(Error _) => FailResultT2(); - /// - /// Executes this member. - /// protected static Task> FailResultTaskT2() => Task.FromResult(FailResultT2()); - /// - /// Executes this member. - /// protected static Task> FailResultTaskT2(Error _) => FailResultTaskT2(); - /// - /// Executes this member. - /// protected static T2 FailT1T2(T1 _) => FailT2(); - /// - /// Executes this member. - /// protected static Task FailTaskT1T2(T1 _) => FailTaskT2(); - /// - /// Executes this member. - /// protected static Result FailResultT1T2(T1 _) => FailResultT2(); - /// - /// Executes this member. - /// protected static Task> FailResultTaskT1T2(T1 _) => FailResultTaskT2(); - /// - /// Executes this member. - /// protected void BaseAssertSuccess(Result output) { FuncExecuted.ShouldBeTrue(); output.IsSuccess.ShouldBeTrue(); } - /// - /// Executes this member. - /// protected void BaseAssertSuccess(T expected, Result output) { BaseAssertSuccess(output); output.Value.ShouldBe(expected); } - /// - /// Executes this member. - /// protected void BaseAssertFailure(Result output) { FuncExecuted.ShouldBeFalse(); diff --git a/tests/Vulthil.Results.Tests/Results/ResultTests.cs b/tests/Vulthil.Results.Tests/Results/ResultTests.cs index 9ee4b19..79121da 100644 --- a/tests/Vulthil.Results.Tests/Results/ResultTests.cs +++ b/tests/Vulthil.Results.Tests/Results/ResultTests.cs @@ -2,14 +2,8 @@ namespace Vulthil.Results.Tests.Results; -/// -/// Represents the ResultTests. -/// public sealed class ResultTests : BaseUnitTestCase { - /// - /// Executes this member. - /// [Fact] public void ResultShouldBeSuccess() { @@ -22,9 +16,6 @@ public void ResultShouldBeSuccess() result.Error.ShouldBe(Error.None); } - /// - /// Executes this member. - /// [Fact] public void ResultInternalConstructorShouldThrowIfSuccessAndError() { @@ -35,9 +26,6 @@ public void ResultInternalConstructorShouldThrowIfSuccessAndError() act.ShouldThrow(); } - /// - /// Executes this member. - /// [Theory] [InlineData(["string"])] [InlineData([1])] @@ -59,9 +47,6 @@ public void ResultShouldBeSuccessWithValue(T value) implicitOperator.Error.ShouldBe(Error.None); } - /// - /// Executes this member. - /// [Fact] public void ResultShouldBeFailure() { @@ -77,9 +62,6 @@ public void ResultShouldBeFailure() result.Error.Type.ShouldBe(Error.NullValue.Type); } - /// - /// Executes this member. - /// [Fact] public void ResultShouldThrowIfFailureIsNone() { @@ -90,9 +72,6 @@ public void ResultShouldThrowIfFailureIsNone() act.ShouldThrow(); } - /// - /// Executes this member. - /// [Fact] public void ResultShouldThrowFailureWithValue() { @@ -113,9 +92,6 @@ public void ResultShouldThrowFailureWithValue() Should.Throw(() => implicitOperator.Value); } - /// - /// Executes this member. - /// [Fact] public void ResultShouldReturnValidationError() { diff --git a/tests/Vulthil.Results.Tests/Results/TapResultBaseTestCase.cs b/tests/Vulthil.Results.Tests/Results/TapResultBaseTestCase.cs index 55721c2..337b9ac 100644 --- a/tests/Vulthil.Results.Tests/Results/TapResultBaseTestCase.cs +++ b/tests/Vulthil.Results.Tests/Results/TapResultBaseTestCase.cs @@ -2,57 +2,33 @@ namespace Vulthil.Results.Tests.Results; -/// -/// Represents the TapResultBaseTestCase. -/// public abstract class TapResultBaseTestCase : ResultBaseTestCase { - /// - /// Gets or sets this member value. - /// protected T1? Param { get; private set; } - /// - /// Executes this member. - /// protected void Func() { FuncExecuted = true; } - /// - /// Executes this member. - /// protected Task TaskFunc() { Func(); return Task.CompletedTask; } - /// - /// Executes this member. - /// protected void FuncT1(T1 _) { Func(); Param = _; } - /// - /// Executes this member. - /// protected Task TaskFuncT1(T1 _) { FuncT1(_); return TaskFunc(); } - /// - /// Executes this member. - /// protected void AssertSuccess(Result output) => BaseAssertSuccess(output); - /// - /// Executes this member. - /// protected void AssertFailure(Result output) => BaseAssertFailure(output); } diff --git a/tests/Vulthil.Results.Tests/Results/TapResultExtensionsTests.cs b/tests/Vulthil.Results.Tests/Results/TapResultExtensionsTests.cs index e6124d6..37a88d9 100644 --- a/tests/Vulthil.Results.Tests/Results/TapResultExtensionsTests.cs +++ b/tests/Vulthil.Results.Tests/Results/TapResultExtensionsTests.cs @@ -1,13 +1,7 @@ namespace Vulthil.Results.Tests.Results; -/// -/// Represents the TapResultExtensionsTests. -/// public sealed class TapResultExtensionsTests : TapResultBaseTestCase { - /// - /// Executes this member. - /// [Fact] public void TapResultSuccess() { @@ -21,9 +15,6 @@ public void TapResultSuccess() AssertSuccess(result2); } - /// - /// Executes this member. - /// [Fact] public void TapResultSuccessT1() { @@ -38,9 +29,6 @@ public void TapResultSuccessT1() result.ShouldBe(result); } - /// - /// Executes this member. - /// [Fact] public void TapResultSuccessT1T1() { @@ -56,9 +44,6 @@ public void TapResultSuccessT1T1() Param.ShouldBe(result.Value); } - /// - /// Executes this member. - /// [Fact] public void TapResultFailure() { @@ -72,9 +57,6 @@ public void TapResultFailure() AssertFailure(result2); } - /// - /// Executes this member. - /// [Fact] public void TapResultFailureT1() { @@ -88,9 +70,6 @@ public void TapResultFailureT1() AssertFailure(result2); } - /// - /// Executes this member. - /// [Fact] public void TapResultFailureT1T1() { @@ -105,9 +84,6 @@ public void TapResultFailureT1T1() Param.ShouldBeNull(); } - /// - /// Executes this member. - /// [Fact] public async Task TapAsyncResult() { @@ -122,9 +98,6 @@ public async Task TapAsyncResult() } - /// - /// Executes this member. - /// [Fact] public async Task TapAsyncResultLeft() { @@ -138,9 +111,6 @@ public async Task TapAsyncResultLeft() AssertSuccess(await task); } - /// - /// Executes this member. - /// [Fact] public async Task TapAsyncResultRight() { @@ -154,9 +124,6 @@ public async Task TapAsyncResultRight() AssertSuccess(await task); } - /// - /// Executes this member. - /// [Fact] public async Task TapAsyncResultFailure() { @@ -170,9 +137,6 @@ public async Task TapAsyncResultFailure() AssertFailure(await task); } - /// - /// Executes this member. - /// [Fact] public async Task TapAsyncResultFailureLeft() { @@ -186,9 +150,6 @@ public async Task TapAsyncResultFailureLeft() AssertFailure(await task); } - /// - /// Executes this member. - /// [Fact] public async Task TapAsyncResultFailureRight() { @@ -202,9 +163,6 @@ public async Task TapAsyncResultFailureRight() AssertFailure(await task); } - /// - /// Executes this member. - /// [Fact] public async Task TapAsyncResultSuccessT1() { @@ -218,9 +176,6 @@ public async Task TapAsyncResultSuccessT1() AssertSuccess(await task); } - /// - /// Executes this member. - /// [Fact] public async Task TapAsyncResultSuccessT1Left() { @@ -234,9 +189,6 @@ public async Task TapAsyncResultSuccessT1Left() AssertSuccess(await task); } - /// - /// Executes this member. - /// [Fact] public async Task TapAsyncResultSuccessT1Right() { @@ -250,9 +202,6 @@ public async Task TapAsyncResultSuccessT1Right() AssertSuccess(await task); } - /// - /// Executes this member. - /// [Fact] public async Task TapAsyncResultSuccessT1T1() { @@ -267,9 +216,6 @@ public async Task TapAsyncResultSuccessT1T1() Param.ShouldBe(T1.Value); } - /// - /// Executes this member. - /// [Fact] public async Task TapAsyncResultSuccessT1T1Left() { @@ -284,9 +230,6 @@ public async Task TapAsyncResultSuccessT1T1Left() Param.ShouldBe(T1.Value); } - /// - /// Executes this member. - /// [Fact] public async Task TapAsyncResultSuccessT1T1Right() { @@ -301,9 +244,6 @@ public async Task TapAsyncResultSuccessT1T1Right() Param.ShouldBe(T1.Value); } - /// - /// Executes this member. - /// [Fact] public async Task TapAsyncResultFailureT1() { @@ -317,9 +257,6 @@ public async Task TapAsyncResultFailureT1() AssertFailure(await task); } - /// - /// Executes this member. - /// [Fact] public async Task TapAsyncResultFailureT1Left() { @@ -333,9 +270,6 @@ public async Task TapAsyncResultFailureT1Left() AssertFailure(await task); } - /// - /// Executes this member. - /// [Fact] public async Task TapAsyncResultFailureT1Right() { @@ -349,9 +283,6 @@ public async Task TapAsyncResultFailureT1Right() AssertFailure(await task); } - /// - /// Executes this member. - /// [Fact] public async Task TapAsyncResultFailureT1T1() { @@ -366,9 +297,6 @@ public async Task TapAsyncResultFailureT1T1() Param.ShouldBeNull(); } - /// - /// Executes this member. - /// [Fact] public async Task TapAsyncResultFailureT1T1Left() { @@ -383,9 +311,6 @@ public async Task TapAsyncResultFailureT1T1Left() Param.ShouldBeNull(); } - /// - /// Executes this member. - /// [Fact] public async Task TapAsyncResultFailureT1T1Right() { diff --git a/tests/Vulthil.Results.Tests/Results/ToResultExtensionsTests.cs b/tests/Vulthil.Results.Tests/Results/ToResultExtensionsTests.cs index 06f3f5c..0c73715 100644 --- a/tests/Vulthil.Results.Tests/Results/ToResultExtensionsTests.cs +++ b/tests/Vulthil.Results.Tests/Results/ToResultExtensionsTests.cs @@ -1,8 +1,5 @@ namespace Vulthil.Results.Tests.Results; -/// -/// Represents the ToResultExtensionsTests. -/// public sealed class ToResultExtensionsTests : ResultBaseTestCase { private static void AssertSuccess(Result result) @@ -16,9 +13,6 @@ private static void AssertFailure(Result result) result.Error.ShouldBe(NullError); } - /// - /// Executes this member. - /// [Fact] public void ToResultStruct() { @@ -32,9 +26,6 @@ public void ToResultStruct() AssertSuccess(result); } - /// - /// Executes this member. - /// [Fact] public void ToResultNullStruct() { @@ -48,9 +39,6 @@ public void ToResultNullStruct() AssertFailure(result); } - /// - /// Executes this member. - /// [Fact] public void ToResultObject() { @@ -64,9 +52,6 @@ public void ToResultObject() AssertSuccess(result); } - /// - /// Executes this member. - /// [Fact] public void ToResultNullObject() { @@ -80,9 +65,6 @@ public void ToResultNullObject() AssertFailure(result); } - /// - /// Executes this member. - /// [Fact] public async Task ToResultAsyncStruct() { @@ -96,9 +78,6 @@ public async Task ToResultAsyncStruct() AssertSuccess(result); } - /// - /// Executes this member. - /// [Fact] public async Task ToResultAsyncNullStruct() { @@ -112,9 +91,6 @@ public async Task ToResultAsyncNullStruct() AssertFailure(result); } - /// - /// Executes this member. - /// [Fact] public async Task ToResultAsyncObject() { @@ -128,9 +104,6 @@ public async Task ToResultAsyncObject() AssertSuccess(result); } - /// - /// Executes this member. - /// [Fact] public async Task ToResultAsyncNullObject() { diff --git a/tests/Vulthil.Results.Tests/ValidationErrorTests.cs b/tests/Vulthil.Results.Tests/ValidationErrorTests.cs index eb23dc4..ca95944 100644 --- a/tests/Vulthil.Results.Tests/ValidationErrorTests.cs +++ b/tests/Vulthil.Results.Tests/ValidationErrorTests.cs @@ -2,14 +2,8 @@ namespace Vulthil.Results.Tests; -/// -/// Represents the ValidationErrorTests. -/// public sealed class ValidationErrorTests : BaseUnitTestCase { - /// - /// Executes this member. - /// [Fact] public void WithSingleErrorCreatesValidationError() { @@ -27,9 +21,6 @@ public void WithSingleErrorCreatesValidationError() e => Assert.Equal(error, e)); } - /// - /// Executes this member. - /// [Fact] public void FromResultsWithMultipleResultsCollectsErrors() { diff --git a/tests/Vulthil.SharedKernel.Application.Tests/DomainEventPublisherTests.cs b/tests/Vulthil.SharedKernel.Application.Tests/DomainEventPublisherTests.cs index 9e785f9..87e9fcc 100644 --- a/tests/Vulthil.SharedKernel.Application.Tests/DomainEventPublisherTests.cs +++ b/tests/Vulthil.SharedKernel.Application.Tests/DomainEventPublisherTests.cs @@ -6,21 +6,12 @@ namespace Vulthil.SharedKernel.Application.Tests; -/// -/// Represents the DomainEventPublisherTests. -/// public sealed class DomainEventPublisherTests : BaseUnitTestCase { private readonly Lazy _lazyTarget; private DomainEventPublisher Target => _lazyTarget.Value; - /// - /// Executes this member. - /// public DomainEventPublisherTests() => _lazyTarget = new(CreateInstance); - /// - /// Executes this member. - /// [Fact] public async Task PublishDomainEventNull() { @@ -31,9 +22,6 @@ public async Task PublishDomainEventNull() await action.ShouldThrowAsync(); } - /// - /// Executes this member. - /// [Fact] public async Task PublishDomainEventNonDomainEvent() { @@ -51,22 +39,13 @@ internal sealed record TestEvent : IDomainEvent; internal sealed class TestEventHandler(TextWriter textWriter) : IDomainEventHandler { - /// - /// Executes this member. - /// public Task HandleAsync(TestEvent notification, CancellationToken cancellationToken = default) => textWriter.WriteLineAsync("Success"); } internal sealed class TestEventHandlerPipeline : IDomainEventPipelineHandler { - /// - /// Executes this member. - /// public Task HandleAsync(TestEvent domainEvent, DomainEventPipelineDelegate next, CancellationToken cancellationToken = default) => next(cancellationToken); } - /// - /// Executes this member. - /// [Fact] public async Task PublishDomainEvent() { diff --git a/tests/Vulthil.SharedKernel.Application.Tests/Pipeline/ValidationPipelineBehaviorTests.cs b/tests/Vulthil.SharedKernel.Application.Tests/Pipeline/ValidationPipelineBehaviorTests.cs index 62a3a17..e42b7e9 100644 --- a/tests/Vulthil.SharedKernel.Application.Tests/Pipeline/ValidationPipelineBehaviorTests.cs +++ b/tests/Vulthil.SharedKernel.Application.Tests/Pipeline/ValidationPipelineBehaviorTests.cs @@ -8,14 +8,8 @@ namespace Vulthil.SharedKernel.Application.Tests.Pipeline; -/// -/// Represents the ValidationPipelineBehaviorTests. -/// public sealed class ValidationPipelineBehaviorTests : BaseUnitTestCase { - /// - /// Executes this member. - /// [Fact] public async Task WithValidRequestCallsNextDelegate() { @@ -42,9 +36,6 @@ public async Task WithValidRequestCallsNextDelegate() Assert.Equal(expectedResult, result); } - /// - /// Executes this member. - /// [Fact] public async Task WithInvalidRequestReturnsValidationError() { @@ -73,13 +64,7 @@ public async Task WithInvalidRequestReturnsValidationError() } } -/// -/// Represents the TestCommand. -/// public class TestCommand : ICommand { - /// - /// Gets or sets this member value. - /// public string Name { get; set; } = string.Empty; } diff --git a/tests/Vulthil.SharedKernel.Tests/Core/AggregateRootTests.cs b/tests/Vulthil.SharedKernel.Tests/Core/AggregateRootTests.cs index a49cf2a..be35c35 100644 --- a/tests/Vulthil.SharedKernel.Tests/Core/AggregateRootTests.cs +++ b/tests/Vulthil.SharedKernel.Tests/Core/AggregateRootTests.cs @@ -4,9 +4,6 @@ namespace Vulthil.SharedKernel.Tests.Core; -/// -/// Represents the AggregateRootTests. -/// public sealed class AggregateRootTests : BaseUnitTestCase { private sealed record TestEntityId(Guid Value); @@ -18,19 +15,10 @@ private TestEntity(TestEntityId testEntityId) : base(testEntityId) } - /// - /// Executes this member. - /// public static TestEntity Create() => new(new(Guid.NewGuid())); - /// - /// Executes this member. - /// public void RaiseEvent() => Raise(new TestEntityEvent(Id.Value)); } - /// - /// Executes this member. - /// [Fact] public void EntityShouldBeConstructable() { @@ -42,9 +30,6 @@ public void EntityShouldBeConstructable() testEntity.DomainEvents.ShouldBeEmpty(); } - /// - /// Executes this member. - /// [Fact] public void EntityShouldBeAbleToRaiseEvents() { @@ -60,9 +45,6 @@ public void EntityShouldBeAbleToRaiseEvents() .Id.ShouldBe(testEntity.Id.Value); } - /// - /// Executes this member. - /// [Fact] public void EntityShouldBeAbleToClearEvents() { diff --git a/tests/Vulthil.SharedKernel.Tests/Core/DomainExceptionTests.cs b/tests/Vulthil.SharedKernel.Tests/Core/DomainExceptionTests.cs index de409c4..027c076 100644 --- a/tests/Vulthil.SharedKernel.Tests/Core/DomainExceptionTests.cs +++ b/tests/Vulthil.SharedKernel.Tests/Core/DomainExceptionTests.cs @@ -4,16 +4,10 @@ namespace Vulthil.SharedKernel.Tests.Core; -/// -/// Represents the DomainExceptionTests. -/// public sealed class DomainExceptionTests : BaseUnitTestCase { private sealed class TestDomainException(Error error) : DomainException(error); - /// - /// Executes this member. - /// [Fact] public void DomainExceptionShouldBeConstructable() { diff --git a/tests/Vulthil.SharedKernel.Tests/Core/IDomainEventHandlerTests.cs b/tests/Vulthil.SharedKernel.Tests/Core/IDomainEventHandlerTests.cs index c7ff831..cbd301e 100644 --- a/tests/Vulthil.SharedKernel.Tests/Core/IDomainEventHandlerTests.cs +++ b/tests/Vulthil.SharedKernel.Tests/Core/IDomainEventHandlerTests.cs @@ -3,23 +3,14 @@ namespace Vulthil.SharedKernel.Tests.Core; -/// -/// Represents the IDomainEventHandlerTests. -/// public sealed class IDomainEventHandlerTests : BaseUnitTestCase { private sealed record TestDomainEvent : IDomainEvent; private sealed class TestDomainHandler : IDomainEventHandler { - /// - /// Executes this member. - /// public Task HandleAsync(TestDomainEvent notification, CancellationToken cancellationToken = default) => Task.CompletedTask; } - /// - /// Executes this member. - /// [Fact] public async Task DomainHandlerShouldHandleAsync() { From a81ec69898f443b2644cc006e2ebbceea3017c3b Mon Sep 17 00:00:00 2001 From: Vulthil Date: Sun, 31 May 2026 21:13:04 +0200 Subject: [PATCH 13/42] feat(messaging): per-aggregate ordering via UsePartitioner --- docs/articles/messaging.md | 58 +++++++ .../Consumers/MessageExecutionPlan.cs | 17 ++ .../Consumers/MessageTypeCache.cs | 36 ++++- .../Consumers/PartitionKeyExtractorFactory.cs | 37 +++++ .../Consumers/RabbitMqConsumerWorker.cs | 150 +++++++++++++++--- src/Vulthil.Messaging.RabbitMq/RabbitMqBus.cs | 14 +- .../IMessageConfigurationProvider.cs | 3 + .../IMessagingConfigurator.cs | 32 ++++ .../MessagingConfigurator.cs | 16 ++ src/Vulthil.Messaging/MessagingOptions.cs | 7 + .../Partitioning/PartitionSpec.cs | 9 ++ .../Partitioning/Partitioner.cs | 117 ++++++++++++++ src/Vulthil.Messaging/PublicAPI.Unshipped.txt | 13 ++ .../Events/OrderedEventConsumer.cs | 28 ++++ .../Program.cs | 10 ++ .../OrderedEvent.cs | 3 + .../Program.cs | 7 + .../MessagingConfigurationTests.cs | 61 +++++++ .../PartitionerTests.cs | 123 ++++++++++++++ 19 files changed, 718 insertions(+), 23 deletions(-) create mode 100644 src/Vulthil.Messaging.RabbitMq/Consumers/PartitionKeyExtractorFactory.cs create mode 100644 src/Vulthil.Messaging/Partitioning/PartitionSpec.cs create mode 100644 src/Vulthil.Messaging/Partitioning/Partitioner.cs create mode 100644 tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ConsumerService/Events/OrderedEventConsumer.cs create mode 100644 tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.Contracts/OrderedEvent.cs create mode 100644 tests/Vulthil.Messaging.Tests/PartitionerTests.cs diff --git a/docs/articles/messaging.md b/docs/articles/messaging.md index d418877..b105933 100644 --- a/docs/articles/messaging.md +++ b/docs/articles/messaging.md @@ -288,6 +288,64 @@ m.ConfigureMessagingOptions(opts => opts.ConsumeFilters.EnableLogging = false); The filter stays registered in DI; only its behavior is skipped, so it's still resolvable in unit tests if you want to assert against it. +## Ordered Processing (per-aggregate) + +Without ordering controls a queue processing messages concurrently does not preserve +order. `UsePartitioner` restores **per-aggregate ordering**: deliveries that +share a partition key are processed one at a time and in publish order, while deliveries +with different keys run concurrently. + +```csharp +builder.AddMessaging(m => +{ + // Order OrderUpdated deliveries per OrderId across 16 lanes. + m.UsePartitioner(partitionCount: 16, ctx => ctx.Message.OrderId.ToString()); + + // The CorrelationId is the natural key when it carries the aggregate id. + m.UsePartitioner(16, ctx => ctx.CorrelationId); + + m.ConfigureQueue("orders", q => q.AddConsumer()); +}); +``` + +Share one `Partitioner` across several message types to serialize messages correlated +to the same key regardless of their type (e.g. a saga): + +```csharp +var orders = new Partitioner(16); +m.UsePartitioner(orders, ctx => ctx.CorrelationId); +m.UsePartitioner(orders, ctx => ctx.CorrelationId); +``` + +### How it works + +Ordering is enforced by the RabbitMQ transport, not a consume filter. When a queue +consumes a partitioned message type, its worker: + +1. Receives deliveries from a **single channel in FIFO order** (`consumerDispatchConcurrency = 1`), + so the partition key is read and the delivery assigned to its lane in arrival order. +2. Hands each delivery to the key's lane and immediately returns, so the next delivery is + laned in order while lanes process **concurrently** (cross-key parallelism). +3. **Acknowledges each message when its lane finishes** (deferred ack). `PrefetchCount` + bounds the number of in-flight deliveries and therefore the effective parallelism. + +Notes: + +- For a partitioned queue, `ConcurrencyLimit` no longer drives dispatch (it is forced to + ordered single dispatch); tune throughput with `PrefetchCount` instead. +- A delivery whose selected key is `null` or empty is processed without lane + serialization (it still runs off the receive loop, so it does not block ordering of + other keys). +- The partition count affects only fan-out (how many distinct keys progress at once), + never correctness. The lane hash is in-process, so a key's lane need not be stable + across processes. +- This preserves order on a **single instance**. Ordering across load-balanced consumers + additionally requires a single active consumer per partition (a later enhancement), + mirroring MassTransit's model. +- Ordering holds for the success path. Preserving order when a handler fails — instead of + the delayed re-delivery used today, which reorders — is handled by the in-place retry + mode tracked separately. + ## Routing Keys Routing keys flow through two distinct configuration sites, one on each side of the wire: diff --git a/src/Vulthil.Messaging.RabbitMq/Consumers/MessageExecutionPlan.cs b/src/Vulthil.Messaging.RabbitMq/Consumers/MessageExecutionPlan.cs index 8808ef0..3eec84d 100644 --- a/src/Vulthil.Messaging.RabbitMq/Consumers/MessageExecutionPlan.cs +++ b/src/Vulthil.Messaging.RabbitMq/Consumers/MessageExecutionPlan.cs @@ -1,4 +1,6 @@ +using RabbitMQ.Client.Events; using Vulthil.Messaging.Queues; +using Vulthil.Messaging.RabbitMq.Envelope; namespace Vulthil.Messaging.RabbitMq.Consumers; @@ -9,4 +11,19 @@ internal sealed record MessageExecutionPlan(MessageType MessageType, Uri Urn) /// The broker is authoritative for delivery (queue-binding filter); every handler in this list runs on every delivery. /// public List Handlers { get; } = []; + + /// + /// The partitioner whose lanes serialize same-key deliveries of this message type, or + /// when the type is not partitioned. + /// + public Partitioner? Partitioner { get; init; } + + /// + /// Extracts the partition key from a delivered message (and its transport metadata), or + /// when the type is not partitioned. + /// + public Func? PartitionKeyExtractor { get; init; } + + /// Gets a value indicating whether deliveries of this message type are partitioned for ordered processing. + public bool IsPartitioned => Partitioner is not null && PartitionKeyExtractor is not null; } diff --git a/src/Vulthil.Messaging.RabbitMq/Consumers/MessageTypeCache.cs b/src/Vulthil.Messaging.RabbitMq/Consumers/MessageTypeCache.cs index 86303db..23ea093 100644 --- a/src/Vulthil.Messaging.RabbitMq/Consumers/MessageTypeCache.cs +++ b/src/Vulthil.Messaging.RabbitMq/Consumers/MessageTypeCache.cs @@ -88,12 +88,46 @@ private MessageExecutionPlan GetOrAddPlan(MessageType messageType) return existing; } - var plan = new MessageExecutionPlan(messageType, urn); + var partition = _provider.GetPartition(messageType.Type); + var plan = new MessageExecutionPlan(messageType, urn) + { + Partitioner = partition?.Partitioner, + PartitionKeyExtractor = partition is null + ? null + : PartitionKeyExtractorFactory.Build(messageType.Type, partition.KeySelector), + }; _plansByUrn[urn] = plan; _plansByFullName[messageType.Name] = plan; return plan; } + /// + /// Indicates whether any concrete message type consumed by is partitioned. + /// Call after so the plans exist. Drives ordered single dispatch on the queue. + /// + public bool IsQueuePartitioned(QueueDefinition queue) + { + foreach (var subscription in queue.Subscriptions) + { + if (GetPlanByUrn(_provider.GetUrn(subscription.MessageType.Type))?.IsPartitioned == true) + { + return true; + } + } + + foreach (var registration in queue.Registrations) + { + var type = registration.MessageType.Type; + if (type is { IsAbstract: false, IsInterface: false } + && GetPlanByUrn(_provider.GetUrn(type))?.IsPartitioned == true) + { + return true; + } + } + + return false; + } + /// /// Resolves a plan from the wire URN (envelope path). Returns when no plan matches. /// diff --git a/src/Vulthil.Messaging.RabbitMq/Consumers/PartitionKeyExtractorFactory.cs b/src/Vulthil.Messaging.RabbitMq/Consumers/PartitionKeyExtractorFactory.cs new file mode 100644 index 0000000..8005f7e --- /dev/null +++ b/src/Vulthil.Messaging.RabbitMq/Consumers/PartitionKeyExtractorFactory.cs @@ -0,0 +1,37 @@ +using System.Reflection; +using RabbitMQ.Client.Events; +using Vulthil.Messaging.Abstractions.Consumers; +using Vulthil.Messaging.RabbitMq.Envelope; +using Vulthil.Messaging.RabbitMq.Sending; + +namespace Vulthil.Messaging.RabbitMq.Consumers; + +/// +/// Builds a type-erased partition-key extractor from a registered typed selector, so the worker can read +/// the key from a deserialized message + delivery without knowing the message type generically. The typed +/// closure is built once per message type via . +/// +internal static class PartitionKeyExtractorFactory +{ + private static readonly MethodInfo BuildTypedMethod = typeof(PartitionKeyExtractorFactory) + .GetMethod(nameof(BuildTyped), BindingFlags.Public | BindingFlags.Static) + ?? throw new InvalidOperationException($"{nameof(PartitionKeyExtractorFactory)}.{nameof(BuildTyped)} not found."); + + public static Func Build(Type messageType, Delegate keySelector) + => (Func)BuildTypedMethod + .MakeGenericMethod(messageType) + .Invoke(null, [keySelector])!; + + public static Func BuildTyped( + Func, string?> selector) + where TMessage : notnull + => (message, ea, envelope) => + { + // A snapshot context (no live publisher/send provider) is sufficient: key selectors read + // metadata and the typed message, they do not publish. + var context = envelope is null + ? MessageContext.CreateContext((TMessage)message, ea) + : MessageContext.CreateContext((TMessage)message, ea, envelope, NullPublisher.Instance, NullSendEndpointProvider.Instance, CancellationToken.None); + return selector(context); + }; +} diff --git a/src/Vulthil.Messaging.RabbitMq/Consumers/RabbitMqConsumerWorker.cs b/src/Vulthil.Messaging.RabbitMq/Consumers/RabbitMqConsumerWorker.cs index 7e26f42..92e88a0 100644 --- a/src/Vulthil.Messaging.RabbitMq/Consumers/RabbitMqConsumerWorker.cs +++ b/src/Vulthil.Messaging.RabbitMq/Consumers/RabbitMqConsumerWorker.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using System.Diagnostics; using System.Globalization; using System.Text.Json; @@ -22,6 +23,8 @@ internal sealed class RabbitMqConsumerWorker : IAsyncDisposable private readonly IMessageConfigurationProvider _messageConfigurationProvider; private readonly ILogger _logger; private readonly int _channelIndex; + private readonly bool _partitioned; + private readonly ConcurrentDictionary _inFlight = new(); private JsonSerializerOptions _jsonOptions => _messageConfigurationProvider.JsonSerializerOptions; @@ -34,7 +37,8 @@ public RabbitMqConsumerWorker( MessageTypeCache messageTypeCache, IMessageConfigurationProvider messageConfigurationProvider, ILogger logger, - int channelIndex) + int channelIndex, + bool partitioned) { _serviceScopeFactory = serviceScopeFactory; _queueDefinition = queue; @@ -43,6 +47,7 @@ public RabbitMqConsumerWorker( _messageConfigurationProvider = messageConfigurationProvider; _logger = logger; _channelIndex = channelIndex; + _partitioned = partitioned; } public async Task StartAsync(CancellationToken cancellationToken) @@ -61,10 +66,93 @@ public async Task StartAsync(CancellationToken cancellationToken) } private async Task OnMessageReceivedAsync(object sender, BasicDeliverEventArgs ea) + { + if (!_partitioned) + { + await ProcessDeliveryAsync(ea); + return; + } + + // Partitioned queue: dispatch is ordered (single channel, dispatch concurrency 1). Assign the + // delivery to its partition lane in arrival order, then return so the next delivery is laned in + // order; the actual processing and ack happen on the lane (deferred ack), giving cross-key + // parallelism bounded by PrefetchCount while preserving per-key order. + var prepared = await TryPrepareAsync(ea); + if (prepared is null) + { + return; + } + + Task work; + if (prepared.Plan.IsPartitioned) + { + var key = prepared.Plan.PartitionKeyExtractor!(prepared.Message, ea, prepared.Envelope); + work = string.IsNullOrEmpty(key) + ? ProcessPreparedAsync(prepared, ea) + : prepared.Plan.Partitioner!.RunSequentialAsync(key, () => ProcessPreparedAsync(prepared, ea)); + } + else + { + // A non-partitioned type sharing a partitioned queue still runs off the receive loop so it does + // not block ordered dispatch of subsequent deliveries. + work = ProcessPreparedAsync(prepared, ea); + } + + TrackInFlight(ea.DeliveryTag, work); + } + + private void TrackInFlight(ulong deliveryTag, Task work) + { + _inFlight[deliveryTag] = work; + _ = work.ContinueWith( + _ => _inFlight.TryRemove(deliveryTag, out Task? _), + CancellationToken.None, + TaskContinuationOptions.DenyChildAttach, + TaskScheduler.Default); + } + + /// Non-partitioned path: process the delivery inline and settle on this dispatcher invocation. + private async Task ProcessDeliveryAsync(BasicDeliverEventArgs ea) { var messageTypeName = ea.BasicProperties.Type ?? ea.Exchange; + using var activity = StartReceiveActivity(ea, messageTypeName); + + try + { + await HandleMessageAsync(ea); + await _channel.BasicAckAsync(ea.DeliveryTag, false); + activity?.SetStatus(ActivityStatusCode.Ok); + } + catch (Exception ex) + { + activity?.SetStatus(ActivityStatusCode.Error, ex.Message); + activity?.AddException(ex); + await HandleFailureAsync(ex, ea, messageTypeName); + } + } + + /// Partitioned path: dispatch the already-prepared delivery's handlers and settle (deferred ack). + private async Task ProcessPreparedAsync(PreparedDelivery prepared, BasicDeliverEventArgs ea) + { + using var activity = StartReceiveActivity(ea, prepared.DiagnosticTypeName); + + try + { + await DispatchHandlersAsync(prepared.Plan, prepared.Message, ea, prepared.Envelope); + await _channel.BasicAckAsync(ea.DeliveryTag, false); + activity?.SetStatus(ActivityStatusCode.Ok); + } + catch (Exception ex) + { + activity?.SetStatus(ActivityStatusCode.Error, ex.Message); + activity?.AddException(ex); + await HandleFailureAsync(ex, ea, prepared.DiagnosticTypeName); + } + } - using var activity = MessagingInstrumentation.ActivitySource.StartActivity( + private Activity? StartReceiveActivity(BasicDeliverEventArgs ea, string messageTypeName) + { + var activity = MessagingInstrumentation.ActivitySource.StartActivity( $"{_queueDefinition.Name} receive", ActivityKind.Consumer); @@ -81,18 +169,7 @@ private async Task OnMessageReceivedAsync(object sender, BasicDeliverEventArgs e activity.SetTag(MessagingInstrumentation.Tags.RetryCount, RabbitMqConstants.GetRetryCount(ea.BasicProperties.Headers)); } - try - { - await HandleMessageAsync(ea); - await _channel.BasicAckAsync(ea.DeliveryTag, false); - activity?.SetStatus(ActivityStatusCode.Ok); - } - catch (Exception ex) - { - activity?.SetStatus(ActivityStatusCode.Error, ex.Message); - activity?.AddException(ex); - await HandleFailureAsync(ex, ea, messageTypeName); - } + return activity; } private async Task HandleFailureAsync(Exception ex, BasicDeliverEventArgs ea, string messageTypeName) @@ -187,8 +264,23 @@ private async Task PublishFaultIfRequestedAsync(Exception ex, BasicDeliverEventA private async Task HandleMessageAsync(BasicDeliverEventArgs ea) { - var bareTypeName = ea.BasicProperties.Type ?? ea.Exchange; + var prepared = await TryPrepareAsync(ea); + if (prepared is null) + { + return; + } + await DispatchHandlersAsync(prepared.Plan, prepared.Message, ea, prepared.Envelope); + } + + /// + /// Parses the envelope, resolves the execution plan, and deserializes the message. Settles the delivery + /// itself for terminal cases — acks (drops) when no plan matches, nacks on a poison/undeserializable body — + /// and returns in those cases. Otherwise returns the prepared delivery for dispatch. + /// + private async Task TryPrepareAsync(BasicDeliverEventArgs ea) + { + var bareTypeName = ea.BasicProperties.Type ?? ea.Exchange; var envelope = TryParseEnvelope(ea.Body, _jsonOptions); var plan = envelope is not null @@ -197,10 +289,11 @@ private async Task HandleMessageAsync(BasicDeliverEventArgs ea) var diagnosticTypeName = envelope?.MessageType.AbsoluteUri ?? bareTypeName; - if (plan == null) + if (plan is null) { MessagingLog.NoExecutionPlan(_logger, _queueDefinition.Name, diagnosticTypeName, ea.RoutingKey); - return; + await _channel.BasicAckAsync(ea.DeliveryTag, false); + return null; } object? message; @@ -214,16 +307,21 @@ private async Task HandleMessageAsync(BasicDeliverEventArgs ea) { MessagingLog.PoisonMessage(_logger, jsonEx, _queueDefinition.Name, diagnosticTypeName, ea.RoutingKey); await _channel.BasicNackAsync(ea.DeliveryTag, false, false); - return; + return null; } if (message is null) { MessagingLog.PoisonMessage(_logger, new JsonException("Deserializer returned null."), _queueDefinition.Name, diagnosticTypeName, ea.RoutingKey); await _channel.BasicNackAsync(ea.DeliveryTag, false, false); - return; + return null; } + return new PreparedDelivery(plan, message, envelope, diagnosticTypeName); + } + + private async Task DispatchHandlersAsync(MessageExecutionPlan plan, object message, BasicDeliverEventArgs ea, MessageEnvelope? envelope) + { await using var scope = _serviceScopeFactory.CreateAsyncScope(); foreach (var handler in plan.Handlers) @@ -263,11 +361,25 @@ public async ValueTask DisposeAsync() await _channel.BasicCancelAsync(_consumerTag); } + // Drain in-flight partitioned work so deferred acks complete before the channel closes; anything + // still unacked after the timeout is requeued by the broker on channel close. + var pending = _inFlight.Values.ToArray(); + if (pending.Length > 0) + { + await Task.WhenAll(pending).WaitAsync(TimeSpan.FromSeconds(30)); + } + await _channel.DisposeAsync(); } catch (ObjectDisposedException) { // Channel was already disposed by AutoRecovery; safe to ignore on shutdown. } + catch (TimeoutException) + { + // Draining took too long; unacked deliveries are requeued when the channel closes. + } } + + private sealed record PreparedDelivery(MessageExecutionPlan Plan, object Message, MessageEnvelope? Envelope, string DiagnosticTypeName); } diff --git a/src/Vulthil.Messaging.RabbitMq/RabbitMqBus.cs b/src/Vulthil.Messaging.RabbitMq/RabbitMqBus.cs index 784c1ab..24740bd 100644 --- a/src/Vulthil.Messaging.RabbitMq/RabbitMqBus.cs +++ b/src/Vulthil.Messaging.RabbitMq/RabbitMqBus.cs @@ -64,12 +64,19 @@ private async Task StartConsumersAsync(IReadOnlyCollection queu { _typeCache.RegisterQueue(queue); - for (int i = 0; i < queue.ChannelCount; i++) + // A partitioned queue must dispatch in FIFO order from a single channel so the worker can assign + // deliveries to partition lanes in arrival order; parallelism comes from the lanes (bounded by + // PrefetchCount) rather than concurrent dispatch. + var partitioned = _typeCache.IsQueuePartitioned(queue); + var channelCount = partitioned ? 1 : queue.ChannelCount; + var dispatchConcurrency = partitioned ? (ushort)1 : queue.ConcurrencyLimit; + + for (int i = 0; i < channelCount; i++) { var options = new CreateChannelOptions( publisherConfirmationsEnabled: false, publisherConfirmationTrackingEnabled: false, - consumerDispatchConcurrency: queue.ConcurrencyLimit + consumerDispatchConcurrency: dispatchConcurrency ); var channel = await _connection.CreateChannelAsync(options, cancellationToken); @@ -82,7 +89,8 @@ private async Task StartConsumersAsync(IReadOnlyCollection queu _typeCache, _messageConfigurationProvider, workerLogger, - i); + i, + partitioned); _workers.Add(worker); } diff --git a/src/Vulthil.Messaging/IMessageConfigurationProvider.cs b/src/Vulthil.Messaging/IMessageConfigurationProvider.cs index e20a81e..760e4eb 100644 --- a/src/Vulthil.Messaging/IMessageConfigurationProvider.cs +++ b/src/Vulthil.Messaging/IMessageConfigurationProvider.cs @@ -67,4 +67,7 @@ public interface IMessageConfigurationProvider /// without re-registering the filter. /// ConsumeFilterOptions ConsumeFilters { get; } + + /// Returns the partition configuration for a message type, or if it is not partitioned. + PartitionSpec? GetPartition(Type messageType); } diff --git a/src/Vulthil.Messaging/IMessagingConfigurator.cs b/src/Vulthil.Messaging/IMessagingConfigurator.cs index ee4fd72..f8640d7 100644 --- a/src/Vulthil.Messaging/IMessagingConfigurator.cs +++ b/src/Vulthil.Messaging/IMessagingConfigurator.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.Hosting; +using Vulthil.Messaging.Abstractions.Consumers; using Vulthil.Messaging.Queues; namespace Vulthil.Messaging; @@ -53,4 +54,35 @@ IMessagingConfigurator ConfigureMessage(ActionAn open generic type (e.g. typeof(LoggingFilter<>)) implementing IConsumeFilter<>. /// The current configurator instance for chaining. IMessagingConfigurator AddOpenConsumeFilter(Type openFilterType); + + /// + /// Serializes processing of deliveries that share a partition key, so + /// messages correlated to the same aggregate are handled one at a time and in order, while messages with + /// different keys process concurrently. Effective only when the consuming queue allows concurrency + /// (ConcurrencyLimit > 1); at a concurrency of one, processing is already serial. + /// + /// The message type to partition. + /// The number of partitions (lanes) to distribute keys across. + /// + /// Selects the partition key from a delivery (e.g. ctx => ctx.CorrelationId). A delivery whose + /// key is or empty bypasses the partitioner. + /// + /// The current configurator instance for chaining. + IMessagingConfigurator UsePartitioner(int partitionCount, Func, string?> keySelector) + where TMessage : notnull; + + /// + /// Serializes processing of deliveries that share a partition key using a + /// caller-supplied . Share one across several message + /// types to serialize messages correlated to the same key regardless of their type (e.g. a saga). + /// + /// The message type to partition. + /// The partitioner whose lanes serialize same-key processing. + /// + /// Selects the partition key from a delivery (e.g. ctx => ctx.CorrelationId). A delivery whose + /// key is or empty bypasses the partitioner. + /// + /// The current configurator instance for chaining. + IMessagingConfigurator UsePartitioner(Partitioner partitioner, Func, string?> keySelector) + where TMessage : notnull; } diff --git a/src/Vulthil.Messaging/MessagingConfigurator.cs b/src/Vulthil.Messaging/MessagingConfigurator.cs index 2daa8ee..0d037c0 100644 --- a/src/Vulthil.Messaging/MessagingConfigurator.cs +++ b/src/Vulthil.Messaging/MessagingConfigurator.cs @@ -152,4 +152,20 @@ public IMessagingConfigurator AddOpenConsumeFilter(Type openFilterType) Services.TryAddEnumerable(new ServiceDescriptor(typeof(IConsumeFilter<>), openFilterType, ServiceLifetime.Scoped)); return this; } + + public IMessagingConfigurator UsePartitioner(int partitionCount, Func, string?> keySelector) + where TMessage : notnull + => UsePartitioner(new Partitioner(partitionCount), keySelector); + + public IMessagingConfigurator UsePartitioner(Partitioner partitioner, Func, string?> keySelector) + where TMessage : notnull + { + ArgumentNullException.ThrowIfNull(partitioner); + ArgumentNullException.ThrowIfNull(keySelector); + + // Recorded for the transport, which extracts the key and dispatches same-key deliveries through the + // partitioner's lanes in arrival order (ordered fan-out + deferred ack), rather than via a consume filter. + _messagingOptions.RegisterPartition(typeof(TMessage), new PartitionSpec(partitioner, keySelector)); + return this; + } } diff --git a/src/Vulthil.Messaging/MessagingOptions.cs b/src/Vulthil.Messaging/MessagingOptions.cs index f635439..1d32b13 100644 --- a/src/Vulthil.Messaging/MessagingOptions.cs +++ b/src/Vulthil.Messaging/MessagingOptions.cs @@ -16,6 +16,7 @@ internal sealed class MessagingOptions : IMessagingOptionsConfigurator, IMessage private readonly Dictionary _typeConfigurations = []; private readonly Dictionary _urnToType = []; private readonly HashSet _registeredRequestTypes = []; + private readonly Dictionary _partitions = []; internal Dictionary MessageConfigurations { get; } = new(StringComparer.Ordinal); internal Dictionary QueueDefinitions { get; } = new(StringComparer.OrdinalIgnoreCase); @@ -64,6 +65,12 @@ public MessageConfiguration GetMessageConfiguration() where TMessage : internal bool RegisterRequestType(MessageType messageType) => _registeredRequestTypes.Add(messageType); + /// Records the partition configuration for a message type (overwrites any prior registration). + internal void RegisterPartition(Type messageType, PartitionSpec spec) => _partitions[messageType] = spec; + + /// + public PartitionSpec? GetPartition(Type messageType) => _partitions.GetValueOrDefault(messageType); + /// /// Records a CLR type ↔ mapping and updates the URN reverse index. /// Idempotent on repeated calls for the same type; throws if two distinct types claim the same URN. diff --git a/src/Vulthil.Messaging/Partitioning/PartitionSpec.cs b/src/Vulthil.Messaging/Partitioning/PartitionSpec.cs new file mode 100644 index 0000000..05114ad --- /dev/null +++ b/src/Vulthil.Messaging/Partitioning/PartitionSpec.cs @@ -0,0 +1,9 @@ +namespace Vulthil.Messaging; + +/// +/// Records how a message type is partitioned for ordered consumption: the +/// whose lanes serialize same-key processing, and the typed key selector (held as a +/// because the message type is only known generically at registration). The transport resolves the +/// concrete selector via the registered message type. +/// +public sealed record PartitionSpec(Partitioner Partitioner, Delegate KeySelector); diff --git a/src/Vulthil.Messaging/Partitioning/Partitioner.cs b/src/Vulthil.Messaging/Partitioning/Partitioner.cs new file mode 100644 index 0000000..7d6a149 --- /dev/null +++ b/src/Vulthil.Messaging/Partitioning/Partitioner.cs @@ -0,0 +1,117 @@ +using System.Buffers; +using System.Text; + +namespace Vulthil.Messaging; + +/// +/// Distributes work across a fixed number of ordered partitions ("lanes"), selected by a hash of a +/// partition key. Work submitted for the same key runs strictly sequentially and in submission order; +/// work for keys that hash to different lanes runs concurrently. +/// +/// +/// Used on the consume side to give per-aggregate ordering — messages correlated to the same aggregate +/// are processed one at a time and in order, while unrelated messages still process in parallel. A single +/// instance can be shared across multiple message types so that messages correlated to the same key are +/// serialized together regardless of their type. The hash is in-process only, so the lane assignment for a +/// given key need not be stable across processes. +/// +public sealed class Partitioner +{ + private readonly object[] _gates; + private readonly Task[] _tails; + + /// + /// Gets the number of partitions (lanes) this partitioner distributes work across. + /// + public int PartitionCount { get; } + + /// + /// Initializes a new with the specified number of partitions. + /// + /// + /// The number of lanes. A larger count lets more distinct keys make progress concurrently; the count + /// affects only fan-out, never correctness. + /// + /// Thrown when is not positive. + public Partitioner(int partitionCount) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(partitionCount); + + PartitionCount = partitionCount; + _gates = new object[partitionCount]; + _tails = new Task[partitionCount]; + for (var i = 0; i < partitionCount; i++) + { + _gates[i] = new object(); + _tails[i] = Task.CompletedTask; + } + } + + /// + /// Queues on the lane selected by , running it only after + /// all work already queued on that lane has completed. Submissions for the same key therefore run + /// sequentially and in submission order; a failed unit of work does not block subsequent work on the lane. + /// + /// The partition key. + /// The work to run on the key's lane. + /// A task that completes when completes (and faults if it faults). + public Task RunSequentialAsync(string key, Func work) + { + ArgumentNullException.ThrowIfNull(key); + ArgumentNullException.ThrowIfNull(work); + + var lane = GetPartition(key); + lock (_gates[lane]) + { + // Enqueue under the lane lock so submission order == lane order. The previous tail is awaited + // with SuppressThrowing so one failed item never wedges the lane; the caller still observes the + // outcome of its own work via the returned task. + var mine = AwaitThenRunAsync(_tails[lane], work); + _tails[lane] = mine; + return mine; + } + } + + private static async Task AwaitThenRunAsync(Task previous, Func work) + { + await previous.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing); + await work().ConfigureAwait(false); + } + + /// Resolves the lane index a key maps to. Exposed for tests. + internal int GetPartition(string key) => (int)(Hash(key) % (uint)PartitionCount); + + // FNV-1a (32-bit) over the UTF-8 bytes of the key. The hash only needs to be deterministic within the + // process (lanes are in-memory), so a simple, well-distributed hash is sufficient. + private static uint Hash(string key) + { + const uint offsetBasis = 2166136261u; + const uint prime = 16777619u; + + byte[]? rented = null; + var maxByteCount = Encoding.UTF8.GetMaxByteCount(key.Length); + Span buffer = maxByteCount <= 512 + ? stackalloc byte[512] + : (rented = ArrayPool.Shared.Rent(maxByteCount)); + + try + { + var written = Encoding.UTF8.GetBytes(key, buffer); + var hash = offsetBasis; + foreach (var b in buffer[..written]) + { + hash ^= b; + hash *= prime; + } + + return hash; + } + finally + { + if (rented is not null) + { + ArrayPool.Shared.Return(rented); + } + } + } +} diff --git a/src/Vulthil.Messaging/PublicAPI.Unshipped.txt b/src/Vulthil.Messaging/PublicAPI.Unshipped.txt index 48e5cb6..61ef90f 100644 --- a/src/Vulthil.Messaging/PublicAPI.Unshipped.txt +++ b/src/Vulthil.Messaging/PublicAPI.Unshipped.txt @@ -12,6 +12,7 @@ Vulthil.Messaging.IMessageConfigurationProvider.FaultExchangeName.get -> string! Vulthil.Messaging.IMessageConfigurationProvider.GetMessageConfiguration(System.Type! messageType) -> Vulthil.Messaging.MessageConfiguration! Vulthil.Messaging.IMessageConfigurationProvider.GetMessageConfiguration() -> Vulthil.Messaging.MessageConfiguration! Vulthil.Messaging.IMessageConfigurationProvider.GetMessageType(System.Uri! urn) -> System.Type? +Vulthil.Messaging.IMessageConfigurationProvider.GetPartition(System.Type! messageType) -> Vulthil.Messaging.PartitionSpec? Vulthil.Messaging.IMessageConfigurationProvider.GetUrn(System.Type! messageType) -> System.Uri! Vulthil.Messaging.IMessageConfigurationProvider.JsonSerializerOptions.get -> System.Text.Json.JsonSerializerOptions! Vulthil.Messaging.IMessageConfigurationProvider.QueueDefinitions.get -> System.Collections.Generic.IReadOnlyCollection! @@ -59,6 +60,13 @@ Vulthil.Messaging.MessagingExchangeType.Direct = 1 -> Vulthil.Messaging.Messagin Vulthil.Messaging.MessagingExchangeType.Fanout = 0 -> Vulthil.Messaging.MessagingExchangeType Vulthil.Messaging.MessagingExchangeType.Headers = 3 -> Vulthil.Messaging.MessagingExchangeType Vulthil.Messaging.MessagingExchangeType.Topic = 2 -> Vulthil.Messaging.MessagingExchangeType +Vulthil.Messaging.Partitioner.RunSequentialAsync(string! key, System.Func! work) -> System.Threading.Tasks.Task! +Vulthil.Messaging.PartitionSpec +Vulthil.Messaging.PartitionSpec.KeySelector.get -> System.Delegate! +Vulthil.Messaging.PartitionSpec.KeySelector.init -> void +Vulthil.Messaging.PartitionSpec.Partitioner.get -> Vulthil.Messaging.Partitioner! +Vulthil.Messaging.PartitionSpec.Partitioner.init -> void +Vulthil.Messaging.PartitionSpec.PartitionSpec(Vulthil.Messaging.Partitioner! Partitioner, System.Delegate! KeySelector) -> void Vulthil.Messaging.Queues.BaseConfigurator Vulthil.Messaging.Queues.BaseConfigurator.BaseConfigurator() -> void Vulthil.Messaging.Queues.BaseConfigurator.Self.get -> TConfigurator! @@ -165,3 +173,8 @@ Vulthil.Messaging.Queues.RetryPolicyDefinition.JitterFactor.get -> double Vulthil.Messaging.Queues.RetryPolicyDefinition.JitterFactor.set -> void Vulthil.Messaging.Queues.RetryPolicyDefinition.MaxRetryCount.get -> int Vulthil.Messaging.Queues.RetryPolicyDefinition.MaxRetryCount.set -> void +Vulthil.Messaging.Partitioner +Vulthil.Messaging.Partitioner.Partitioner(int partitionCount) -> void +Vulthil.Messaging.Partitioner.PartitionCount.get -> int +Vulthil.Messaging.IMessagingConfigurator.UsePartitioner(int partitionCount, System.Func!, string?>! keySelector) -> Vulthil.Messaging.IMessagingConfigurator! +Vulthil.Messaging.IMessagingConfigurator.UsePartitioner(Vulthil.Messaging.Partitioner! partitioner, System.Func!, string?>! keySelector) -> Vulthil.Messaging.IMessagingConfigurator! diff --git a/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ConsumerService/Events/OrderedEventConsumer.cs b/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ConsumerService/Events/OrderedEventConsumer.cs new file mode 100644 index 0000000..cc8126f --- /dev/null +++ b/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ConsumerService/Events/OrderedEventConsumer.cs @@ -0,0 +1,28 @@ +using Microsoft.Extensions.Logging; +using Vulthil.Messaging.Abstractions.Consumers; +using Vulthil.Messaging.IntegrationTest.ConsumerService.Infrastructure; +using Vulthil.Messaging.IntegrationTest.Contracts; + +namespace Vulthil.Messaging.IntegrationTest.ConsumerService.Events; + +public sealed partial class OrderedEventConsumer( + ILogger logger, + ReceivedMessageTracker tracker) : IConsumer +{ + public async Task ConsumeAsync(IMessageContext messageContext, CancellationToken cancellationToken = default) + { + var message = messageContext.Message; + LogReceived(logger, message.Key, message.Sequence); + + // Even sequences process slower than odd ones. Without per-key ordering and with queue + // concurrency, a faster later message would overtake a slower earlier one and be recorded + // out of order — so a strictly increasing recorded sequence proves the partitioner works. + var delayMs = message.Sequence % 2 == 0 ? 100 : 10; + await Task.Delay(delayMs, cancellationToken); + + tracker.Record($"ordered-{message.Key}", message.Sequence); + } + + [LoggerMessage(EventId = 1, Level = LogLevel.Information, Message = "Received OrderedEvent {Key}#{Sequence}")] + private static partial void LogReceived(ILogger logger, string key, int sequence); +} diff --git a/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ConsumerService/Program.cs b/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ConsumerService/Program.cs index ef804ac..96a769e 100644 --- a/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ConsumerService/Program.cs +++ b/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ConsumerService/Program.cs @@ -45,6 +45,9 @@ // Cross-cutting consume filter: records the message type of every delivery it wraps. messaging.AddOpenConsumeFilter(typeof(AuditConsumeFilter<>)); + // Per-aggregate ordering: serialize OrderedEvent deliveries by their Key across lanes. + messaging.UsePartitioner(8, context => context.Message.Key); + messaging.ConfigureQueue("weather-events", queue => { queue.AddConsumer(); @@ -100,6 +103,13 @@ queue.AddConsumer(); }); + // Ordered processing: queue runs with concurrency, partitioner keeps per-key order. + messaging.ConfigureQueue("ordered-events", queue => + { + queue.ConfigureQueue(definition => definition.ConcurrencyLimit = 8); + queue.AddConsumer(); + }); + messaging.UseRabbitMq("rabbitmq"); }); diff --git a/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.Contracts/OrderedEvent.cs b/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.Contracts/OrderedEvent.cs new file mode 100644 index 0000000..97b6ad9 --- /dev/null +++ b/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.Contracts/OrderedEvent.cs @@ -0,0 +1,3 @@ +namespace Vulthil.Messaging.IntegrationTest.Contracts; + +public sealed record OrderedEvent(string Key, int Sequence); diff --git a/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ProducerService/Program.cs b/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ProducerService/Program.cs index de3f050..4e16940 100644 --- a/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ProducerService/Program.cs +++ b/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ProducerService/Program.cs @@ -65,6 +65,13 @@ }) .WithName("PublishStockChangedEvent"); +api.MapPost("publish-ordered", async (OrderedEvent message, IPublisher publisher, CancellationToken cancellationToken) => +{ + await publisher.PublishAsync(message, cancellationToken: cancellationToken); + return Results.Accepted(value: message); +}) +.WithName("PublishOrderedEvent"); + api.MapPost("send-command", async (RecordWeatherCommand message, IPublisher publisher, CancellationToken cancellationToken) => { await publisher.PublishAsync(message, cancellationToken: cancellationToken); diff --git a/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.Tests/MessagingConfigurationTests.cs b/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.Tests/MessagingConfigurationTests.cs index ce30d26..199e7bf 100644 --- a/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.Tests/MessagingConfigurationTests.cs +++ b/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.Tests/MessagingConfigurationTests.cs @@ -244,6 +244,67 @@ public async Task RequestWithShortPerRequestTimeoutSurfacesATimeoutFailure() stopwatch.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(12)); } + [Fact] + public async Task PartitionerPreservesPerKeyOrderUnderConcurrency() + { + var cancellationToken = TestContext.Current.CancellationToken; + const int count = 10; + var keyA = $"A-{Guid.NewGuid():N}"; + var keyB = $"B-{Guid.NewGuid():N}"; + + // Interleave the two keys so the broker delivers them mixed and the queue (concurrency 8) + // would process them out of order without the partitioner. + for (var sequence = 0; sequence < count; sequence++) + { + foreach (var key in new[] { keyA, keyB }) + { + using var response = await fixture.ProducerClient.PostAsJsonAsync( + "/api/publish-ordered", + new OrderedEvent(key, sequence), + cancellationToken); + response.IsSuccessStatusCode.ShouldBeTrue(); + } + } + + var expected = Enumerable.Range(0, count).ToList(); + + var resultA = await Polling.WaitAsync( + PollTimeout, + ct => TryGetSequencesAsync(fixture.ConsumerClient, keyA, count, ct), + PollInterval, + cancellationToken); + + var resultB = await Polling.WaitAsync( + PollTimeout, + ct => TryGetSequencesAsync(fixture.ConsumerClient, keyB, count, ct), + PollInterval, + cancellationToken); + + resultA.IsSuccess.ShouldBeTrue(); + resultB.IsSuccess.ShouldBeTrue(); + resultA.Value.ShouldBe(expected); + resultB.Value.ShouldBe(expected); + } + + private static async Task>> TryGetSequencesAsync( + HttpClient client, + string key, + int expectedCount, + CancellationToken cancellationToken) + { + try + { + var items = await client.GetFromJsonAsync>($"/api/received/ordered-{key}", cancellationToken); + return items is not null && items.Count >= expectedCount + ? Result.Success(items) + : Result.Failure>(Error.NotFound("Polling.Incomplete", $"Have {items?.Count ?? 0}/{expectedCount} for {key}.")); + } + catch (HttpRequestException ex) + { + return Result.Failure>(Error.Failure("Polling.HttpRequest", ex.Message)); + } + } + private static async Task> TryFindMatchAsync( HttpClient client, string endpoint, diff --git a/tests/Vulthil.Messaging.Tests/PartitionerTests.cs b/tests/Vulthil.Messaging.Tests/PartitionerTests.cs new file mode 100644 index 0000000..1138e48 --- /dev/null +++ b/tests/Vulthil.Messaging.Tests/PartitionerTests.cs @@ -0,0 +1,123 @@ +using System.Collections.Concurrent; +using Microsoft.Extensions.Hosting; +using Vulthil.xUnit; + +namespace Vulthil.Messaging.Tests; + +public sealed class PartitionerTests : BaseUnitTestCase +{ + [Fact] + public void ConstructorRejectsNonPositivePartitionCount() + { + // Act & Assert + Should.Throw(() => new Partitioner(0)); + Should.Throw(() => new Partitioner(-1)); + } + + [Fact] + public async Task SameKeyRunsSequentiallyAndInSubmissionOrder() + { + // Arrange + var partitioner = new Partitioner(16); + var order = new ConcurrentQueue(); + var concurrent = 0; + + async Task Work(int index) + { + Interlocked.Increment(ref concurrent).ShouldBe(1, "same-key work must never run concurrently"); + order.Enqueue(index); + await Task.Delay(5, CancellationToken); + Interlocked.Decrement(ref concurrent); + } + + // Act — submit 8 items for the same key in order 0..7. + var tasks = Enumerable.Range(0, 8) + .Select(i => partitioner.RunSequentialAsync("same-key", () => Work(i))) + .ToArray(); + await Task.WhenAll(tasks); + + // Assert + order.ShouldBe(Enumerable.Range(0, 8)); + } + + [Fact] + public async Task DifferentKeysRunConcurrently() + { + // Arrange + var cancellationToken = CancellationToken; + var partitioner = new Partitioner(16); + var (keyA, keyB) = FindKeysOnDifferentLanes(partitioner); + + using var bothStarted = new CountdownEvent(2); + using var release = new ManualResetEventSlim(false); + + Task Work() => Task.Run(() => + { + bothStarted.Signal(); + release.Wait(TimeSpan.FromSeconds(5), cancellationToken); + }, cancellationToken); + + // Act + var a = partitioner.RunSequentialAsync(keyA, Work); + var b = partitioner.RunSequentialAsync(keyB, Work); + var bothRanConcurrently = bothStarted.Wait(TimeSpan.FromSeconds(5), cancellationToken); + release.Set(); + await Task.WhenAll(a, b); + + // Assert + bothRanConcurrently.ShouldBeTrue("work on different lanes must be able to run at the same time"); + } + + [Fact] + public async Task FaultedWorkDoesNotBlockSubsequentWorkOnTheSameLane() + { + // Arrange + var partitioner = new Partitioner(4); + var secondRan = false; + + // Act + var faulting = partitioner.RunSequentialAsync("key", () => throw new InvalidOperationException("boom")); + var next = partitioner.RunSequentialAsync("key", () => + { + secondRan = true; + return Task.CompletedTask; + }); + + // Assert + await Should.ThrowAsync(() => faulting); + await next; + secondRan.ShouldBeTrue(); + } + + [Fact] + public void UsePartitionerRegistersThePartitionForTheMessageType() + { + // Arrange + var options = new MessagingOptions(); + var configurator = new MessagingConfigurator(Host.CreateApplicationBuilder(), options); + + // Act + configurator.UsePartitioner(8, context => context.CorrelationId); + + // Assert + options.GetPartition(typeof(PartitionTestMessage)).ShouldNotBeNull(); + } + + private static (string KeyA, string KeyB) FindKeysOnDifferentLanes(Partitioner partitioner) + { + const string first = "key-0"; + var firstLane = partitioner.GetPartition(first); + for (var i = 1; i < 1000; i++) + { + var candidate = $"key-{i}"; + if (partitioner.GetPartition(candidate) != firstLane) + { + return (first, candidate); + } + } + + throw new InvalidOperationException("Could not find two keys mapping to different lanes."); + } + + private sealed record PartitionTestMessage(string Value); +} From dc24d8bb66ed01538a15c6d43e636ff4e64bb18a Mon Sep 17 00:00:00 2001 From: Vulthil Date: Sun, 31 May 2026 21:32:10 +0200 Subject: [PATCH 14/42] feat(messaging): add in-memory retry mode preserving per-key order on failure --- docs/articles/messaging.md | 21 +++- .../Consumers/RabbitMqConsumerWorker.cs | 119 +++++++++++------- src/Vulthil.Messaging/PublicAPI.Unshipped.txt | 3 + .../Queues/IQueueConfigurator.cs | 18 ++- .../Events/OrderedEventConsumer.cs | 8 ++ .../Infrastructure/ReceivedMessageTracker.cs | 3 + .../Program.cs | 4 +- .../OrderedEvent.cs | 2 +- .../MessagingConfigurationTests.cs | 31 +++++ .../RetryPolicyConfiguratorTests.cs | 21 ++++ 10 files changed, 182 insertions(+), 48 deletions(-) diff --git a/docs/articles/messaging.md b/docs/articles/messaging.md index b105933..50125cf 100644 --- a/docs/articles/messaging.md +++ b/docs/articles/messaging.md @@ -342,9 +342,24 @@ Notes: - This preserves order on a **single instance**. Ordering across load-balanced consumers additionally requires a single active consumer per partition (a later enhancement), mirroring MassTransit's model. -- Ordering holds for the success path. Preserving order when a handler fails — instead of - the delayed re-delivery used today, which reorders — is handled by the in-place retry - mode tracked separately. +- **Failure path:** a partitioned queue retries **in-memory** automatically — the consumer + is re-invoked in-process while the delivery (and its lane) is held — so a failing message + cannot be overtaken by a later same-key message. See *Retries* below. + +## Retries + +`q.UseRetry(...)` configures the retry policy for a queue's consumers. There are two +execution modes: + +- **Delayed re-delivery (default):** a failed message is re-published to the queue's retry + exchange with a delay and re-delivered later. Good for back-off without holding a consumer, + but it **reorders** relative to other messages. +- **In-memory:** the consumer is re-invoked in-process while the delivery is held, preserving + order. Opt in with `q.UseRetry(r => { r.Immediate(3); r.InMemory(); })`. Partitioned queues + use in-memory retry **automatically**, since ordering requires it. + +On exhaustion the message is dead-lettered (when a dead-letter queue is configured) in both +modes. ## Routing Keys diff --git a/src/Vulthil.Messaging.RabbitMq/Consumers/RabbitMqConsumerWorker.cs b/src/Vulthil.Messaging.RabbitMq/Consumers/RabbitMqConsumerWorker.cs index 92e88a0..fc73bc0 100644 --- a/src/Vulthil.Messaging.RabbitMq/Consumers/RabbitMqConsumerWorker.cs +++ b/src/Vulthil.Messaging.RabbitMq/Consumers/RabbitMqConsumerWorker.cs @@ -67,35 +67,35 @@ public async Task StartAsync(CancellationToken cancellationToken) private async Task OnMessageReceivedAsync(object sender, BasicDeliverEventArgs ea) { - if (!_partitioned) + var prepared = await TryPrepareAsync(ea); + if (prepared is null) { - await ProcessDeliveryAsync(ea); return; } - // Partitioned queue: dispatch is ordered (single channel, dispatch concurrency 1). Assign the - // delivery to its partition lane in arrival order, then return so the next delivery is laned in - // order; the actual processing and ack happen on the lane (deferred ack), giving cross-key - // parallelism bounded by PrefetchCount while preserving per-key order. - var prepared = await TryPrepareAsync(ea); - if (prepared is null) + if (!_partitioned) { + await ProcessAsync(prepared, ea); return; } + // Partitioned queue: dispatch is ordered (single channel, dispatch concurrency 1). Assign the + // delivery to its partition lane in arrival order, then return so the next delivery is laned in + // order; processing, retry, and ack happen on the lane (deferred ack), giving cross-key parallelism + // bounded by PrefetchCount while preserving per-key order. Task work; if (prepared.Plan.IsPartitioned) { var key = prepared.Plan.PartitionKeyExtractor!(prepared.Message, ea, prepared.Envelope); work = string.IsNullOrEmpty(key) - ? ProcessPreparedAsync(prepared, ea) - : prepared.Plan.Partitioner!.RunSequentialAsync(key, () => ProcessPreparedAsync(prepared, ea)); + ? ProcessAsync(prepared, ea) + : prepared.Plan.Partitioner!.RunSequentialAsync(key, () => ProcessAsync(prepared, ea)); } else { // A non-partitioned type sharing a partitioned queue still runs off the receive loop so it does // not block ordered dispatch of subsequent deliveries. - work = ProcessPreparedAsync(prepared, ea); + work = ProcessAsync(prepared, ea); } TrackInFlight(ea.DeliveryTag, work); @@ -111,30 +111,22 @@ private void TrackInFlight(ulong deliveryTag, Task work) TaskScheduler.Default); } - /// Non-partitioned path: process the delivery inline and settle on this dispatcher invocation. - private async Task ProcessDeliveryAsync(BasicDeliverEventArgs ea) + /// + /// Dispatches a prepared delivery and settles it. When the effective retry policy is in-memory — set + /// explicitly via UseRetry(r => r.InMemory()) or implied by a partitioned queue — the consumer + /// is retried in-process while the delivery is held (preserving order); otherwise a failure goes through + /// the delay-queue re-delivery path. + /// + private async Task ProcessAsync(PreparedDelivery prepared, BasicDeliverEventArgs ea) { - var messageTypeName = ea.BasicProperties.Type ?? ea.Exchange; - using var activity = StartReceiveActivity(ea, messageTypeName); + using var activity = StartReceiveActivity(ea, prepared.DiagnosticTypeName); + var policy = GetPolicy(prepared.Plan, _queueDefinition); - try + if (policy is not null && (policy.InMemory || _partitioned)) { - await HandleMessageAsync(ea); - await _channel.BasicAckAsync(ea.DeliveryTag, false); - activity?.SetStatus(ActivityStatusCode.Ok); - } - catch (Exception ex) - { - activity?.SetStatus(ActivityStatusCode.Error, ex.Message); - activity?.AddException(ex); - await HandleFailureAsync(ex, ea, messageTypeName); + await ExecuteWithInMemoryRetryAsync(policy, prepared, ea, activity); + return; } - } - - /// Partitioned path: dispatch the already-prepared delivery's handlers and settle (deferred ack). - private async Task ProcessPreparedAsync(PreparedDelivery prepared, BasicDeliverEventArgs ea) - { - using var activity = StartReceiveActivity(ea, prepared.DiagnosticTypeName); try { @@ -150,6 +142,60 @@ private async Task ProcessPreparedAsync(PreparedDelivery prepared, BasicDeliverE } } + /// + /// Re-invokes the consumer in-process up to the policy's retry count, holding the delivery (and, on a + /// partitioned queue, its lane) so a later message cannot overtake the one being retried. Each attempt + /// runs in a fresh scope. On exhaustion the message is faulted (if requested) and nacked for dead-lettering. + /// + private async Task ExecuteWithInMemoryRetryAsync(RetryPolicyDefinition policy, PreparedDelivery prepared, BasicDeliverEventArgs ea, Activity? activity) + { + for (var attempt = 0; attempt <= policy.MaxRetryCount; attempt++) + { + try + { + await DispatchHandlersAsync(prepared.Plan, prepared.Message, ea, prepared.Envelope); + await _channel.BasicAckAsync(ea.DeliveryTag, false); + activity?.SetStatus(ActivityStatusCode.Ok); + return; + } + catch (Exception ex) + { + if (ex is OperationCanceledException && ea.CancellationToken.IsCancellationRequested) + { + // Worker is stopping; leave the delivery unacked so the broker re-delivers it later. + return; + } + + var canRetry = attempt < policy.MaxRetryCount && !policy.GetIgnoredExceptionTypes().Contains(ex.GetType()); + if (!canRetry) + { + activity?.SetStatus(ActivityStatusCode.Error, ex.Message); + activity?.AddException(ex); + MessagingLog.ConsumerFailed(_logger, ex, _queueDefinition.Name, prepared.DiagnosticTypeName, ea.RoutingKey); + await PublishFaultIfRequestedAsync(ex, ea, ea.BasicProperties.Headers ?? new Dictionary()); + await _channel.BasicNackAsync(ea.DeliveryTag, false, requeue: false); + return; + } + + var delay = policy.GetDelay(attempt); + MessagingLog.ConsumerThrew(_logger, ex, _queueDefinition.Name, prepared.DiagnosticTypeName, ea.RoutingKey, attempt, policy.MaxRetryCount); + MessagingLog.SchedulingRetry(_logger, _queueDefinition.Name, attempt + 1, policy.MaxRetryCount, delay); + + if (delay > TimeSpan.Zero) + { + try + { + await Task.Delay(delay, ea.CancellationToken); + } + catch (OperationCanceledException) + { + return; + } + } + } + } + } + private Activity? StartReceiveActivity(BasicDeliverEventArgs ea, string messageTypeName) { var activity = MessagingInstrumentation.ActivitySource.StartActivity( @@ -262,17 +308,6 @@ private async Task PublishFaultIfRequestedAsync(Exception ex, BasicDeliverEventA return queue.DefaultRetryPolicy; } - private async Task HandleMessageAsync(BasicDeliverEventArgs ea) - { - var prepared = await TryPrepareAsync(ea); - if (prepared is null) - { - return; - } - - await DispatchHandlersAsync(prepared.Plan, prepared.Message, ea, prepared.Envelope); - } - /// /// Parses the envelope, resolves the execution plan, and deserializes the message. Settles the delivery /// itself for terminal cases — acks (drops) when no plan matches, nacks on a poison/undeserializable body — diff --git a/src/Vulthil.Messaging/PublicAPI.Unshipped.txt b/src/Vulthil.Messaging/PublicAPI.Unshipped.txt index 61ef90f..6f10cc2 100644 --- a/src/Vulthil.Messaging/PublicAPI.Unshipped.txt +++ b/src/Vulthil.Messaging/PublicAPI.Unshipped.txt @@ -159,6 +159,7 @@ Vulthil.Messaging.Queues.RetryPolicyConfigurator.Exponential(int retryCount, Sys Vulthil.Messaging.Queues.RetryPolicyConfigurator.Ignore() -> void Vulthil.Messaging.Queues.RetryPolicyConfigurator.IgnoredExceptions.get -> System.Collections.Generic.IReadOnlySet! Vulthil.Messaging.Queues.RetryPolicyConfigurator.Immediate(int retryCount) -> void +Vulthil.Messaging.Queues.RetryPolicyConfigurator.InMemory() -> void Vulthil.Messaging.Queues.RetryPolicyConfigurator.Intervals.get -> System.Collections.Generic.IReadOnlyList! Vulthil.Messaging.Queues.RetryPolicyConfigurator.RetryLimit.get -> int Vulthil.Messaging.Queues.RetryPolicyConfigurator.RetryPolicyConfigurator() -> void @@ -168,6 +169,8 @@ Vulthil.Messaging.Queues.RetryPolicyDefinition Vulthil.Messaging.Queues.RetryPolicyDefinition.GetDelay(int attempt) -> System.TimeSpan Vulthil.Messaging.Queues.RetryPolicyDefinition.GetIgnoredExceptionTypes() -> System.Collections.Generic.HashSet! Vulthil.Messaging.Queues.RetryPolicyDefinition.IgnoreExceptions.get -> System.Collections.Generic.ICollection! +Vulthil.Messaging.Queues.RetryPolicyDefinition.InMemory.get -> bool +Vulthil.Messaging.Queues.RetryPolicyDefinition.InMemory.set -> void Vulthil.Messaging.Queues.RetryPolicyDefinition.Intervals.get -> System.Collections.Generic.ICollection! Vulthil.Messaging.Queues.RetryPolicyDefinition.JitterFactor.get -> double Vulthil.Messaging.Queues.RetryPolicyDefinition.JitterFactor.set -> void diff --git a/src/Vulthil.Messaging/Queues/IQueueConfigurator.cs b/src/Vulthil.Messaging/Queues/IQueueConfigurator.cs index 651c7ea..319ff89 100644 --- a/src/Vulthil.Messaging/Queues/IQueueConfigurator.cs +++ b/src/Vulthil.Messaging/Queues/IQueueConfigurator.cs @@ -79,6 +79,13 @@ public sealed record RetryPolicyDefinition /// public double JitterFactor { get; set; } /// + /// Gets or sets a value indicating whether retries run in-memory — the consumer is re-invoked in-process + /// while the delivery is held — rather than via delayed re-delivery through the retry queue. In-memory + /// retries preserve message order (a later message cannot overtake the one being retried), so they are + /// used automatically for partitioned queues. Defaults to (delayed re-delivery). + /// + public bool InMemory { get; set; } + /// /// Gets the delay intervals between successive retry attempts. /// public ICollection Intervals { get; } = []; @@ -184,6 +191,7 @@ public sealed class RetryPolicyConfigurator /// public int RetryLimit { get; private set; } private double _jitterFactor; + private bool _inMemory; /// /// Gets the configured delay intervals between successive retry attempts. /// @@ -267,6 +275,13 @@ public void Exponential( /// The exception type to ignore. public void Ignore() where TException : Exception => _ignoredExceptions.Add(typeof(TException)); + /// + /// Configures retries to run in-memory: the consumer is re-invoked in-process while the delivery is held, + /// instead of being re-delivered later through the retry queue. This preserves message order, so it is the + /// behavior used automatically for partitioned queues. + /// + public void InMemory() => _inMemory = true; + /// /// Builds the from the current configuration. /// @@ -276,7 +291,8 @@ public RetryPolicyDefinition Build() var definition = new RetryPolicyDefinition { MaxRetryCount = RetryLimit, - JitterFactor = _jitterFactor + JitterFactor = _jitterFactor, + InMemory = _inMemory }; foreach (var interval in _intervals) diff --git a/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ConsumerService/Events/OrderedEventConsumer.cs b/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ConsumerService/Events/OrderedEventConsumer.cs index cc8126f..47ac1da 100644 --- a/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ConsumerService/Events/OrderedEventConsumer.cs +++ b/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ConsumerService/Events/OrderedEventConsumer.cs @@ -14,6 +14,14 @@ public async Task ConsumeAsync(IMessageContext messageContext, Can var message = messageContext.Message; LogReceived(logger, message.Key, message.Sequence); + // Fail the first FailAttempts invocations. With in-memory retry the delivery is held (and the lane + // with it), so a later same-key message cannot be recorded before this one finally succeeds. + var attempt = tracker.RecordAttempt($"{message.Key}:{message.Sequence}"); + if (attempt <= message.FailAttempts) + { + throw new InvalidOperationException($"Ordered {message.Key}#{message.Sequence} failing attempt {attempt}."); + } + // Even sequences process slower than odd ones. Without per-key ordering and with queue // concurrency, a faster later message would overtake a slower earlier one and be recorded // out of order — so a strictly increasing recorded sequence proves the partitioner works. diff --git a/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ConsumerService/Infrastructure/ReceivedMessageTracker.cs b/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ConsumerService/Infrastructure/ReceivedMessageTracker.cs index 90b7625..af962ae 100644 --- a/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ConsumerService/Infrastructure/ReceivedMessageTracker.cs +++ b/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ConsumerService/Infrastructure/ReceivedMessageTracker.cs @@ -6,6 +6,7 @@ public sealed class ReceivedMessageTracker { private readonly ConcurrentDictionary> _messages = new(); private readonly ConcurrentDictionary _attempts = new(); + private readonly ConcurrentDictionary _namedAttempts = new(); public void Record(string key, object message) => _messages.GetOrAdd(key, _ => new ConcurrentQueue()).Enqueue(message); @@ -15,5 +16,7 @@ public IReadOnlyCollection Get(string key) public int RecordAttempt(Guid id) => _attempts.AddOrUpdate(id, 1, (_, count) => count + 1); + public int RecordAttempt(string key) => _namedAttempts.AddOrUpdate(key, 1, (_, count) => count + 1); + public int GetAttempts(Guid id) => _attempts.GetValueOrDefault(id); } diff --git a/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ConsumerService/Program.cs b/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ConsumerService/Program.cs index 96a769e..e039ea1 100644 --- a/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ConsumerService/Program.cs +++ b/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ConsumerService/Program.cs @@ -103,10 +103,12 @@ queue.AddConsumer(); }); - // Ordered processing: queue runs with concurrency, partitioner keeps per-key order. + // Ordered processing: queue runs with concurrency, partitioner keeps per-key order. Retries run + // in-memory automatically (partitioned queue), so a failing message keeps its lane and order holds. messaging.ConfigureQueue("ordered-events", queue => { queue.ConfigureQueue(definition => definition.ConcurrencyLimit = 8); + queue.UseRetry(retry => retry.Immediate(5)); queue.AddConsumer(); }); diff --git a/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.Contracts/OrderedEvent.cs b/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.Contracts/OrderedEvent.cs index 97b6ad9..ae0df9c 100644 --- a/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.Contracts/OrderedEvent.cs +++ b/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.Contracts/OrderedEvent.cs @@ -1,3 +1,3 @@ namespace Vulthil.Messaging.IntegrationTest.Contracts; -public sealed record OrderedEvent(string Key, int Sequence); +public sealed record OrderedEvent(string Key, int Sequence, int FailAttempts = 0); diff --git a/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.Tests/MessagingConfigurationTests.cs b/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.Tests/MessagingConfigurationTests.cs index 199e7bf..b4b7112 100644 --- a/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.Tests/MessagingConfigurationTests.cs +++ b/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.Tests/MessagingConfigurationTests.cs @@ -286,6 +286,37 @@ public async Task PartitionerPreservesPerKeyOrderUnderConcurrency() resultB.Value.ShouldBe(expected); } + [Fact] + public async Task PartitionedQueueRetriesInMemoryPreservingOrderOnFailure() + { + var cancellationToken = TestContext.Current.CancellationToken; + var key = $"R-{Guid.NewGuid():N}"; + + // Sequence 0 fails its first two attempts; in-memory retry holds the lane while it retries, so + // sequence 1 must not be recorded before sequence 0 finally succeeds. + using (var first = await fixture.ProducerClient.PostAsJsonAsync( + "/api/publish-ordered", new OrderedEvent(key, 0, FailAttempts: 2), cancellationToken)) + { + first.IsSuccessStatusCode.ShouldBeTrue(); + } + + using (var second = await fixture.ProducerClient.PostAsJsonAsync( + "/api/publish-ordered", new OrderedEvent(key, 1), cancellationToken)) + { + second.IsSuccessStatusCode.ShouldBeTrue(); + } + + var result = await Polling.WaitAsync( + PollTimeout, + ct => TryGetSequencesAsync(fixture.ConsumerClient, key, 2, ct), + PollInterval, + cancellationToken); + + var expected = Enumerable.Range(0, 2).ToList(); + result.IsSuccess.ShouldBeTrue(); + result.Value.ShouldBe(expected); + } + private static async Task>> TryGetSequencesAsync( HttpClient client, string key, diff --git a/tests/Vulthil.Messaging.Tests/RetryPolicyConfiguratorTests.cs b/tests/Vulthil.Messaging.Tests/RetryPolicyConfiguratorTests.cs index 75f9f35..d41c444 100644 --- a/tests/Vulthil.Messaging.Tests/RetryPolicyConfiguratorTests.cs +++ b/tests/Vulthil.Messaging.Tests/RetryPolicyConfiguratorTests.cs @@ -32,4 +32,25 @@ public void ImmediateConfiguresZeroDelayIntervals() Target.Intervals.Count.ShouldBe(3); Target.Intervals.ShouldAllBe(interval => interval == TimeSpan.Zero); } + + [Fact] + public void RetryDefaultsToDelayedRedelivery() + { + // Act + Target.Immediate(2); + + // Assert + Target.Build().InMemory.ShouldBeFalse(); + } + + [Fact] + public void InMemoryEnablesInMemoryRetry() + { + // Act + Target.Immediate(2); + Target.InMemory(); + + // Assert + Target.Build().InMemory.ShouldBeTrue(); + } } From bc0f1d53bf6ed4c703c6b38646ff84271b4e98b5 Mon Sep 17 00:00:00 2001 From: Vulthil Date: Mon, 1 Jun 2026 20:14:40 +0200 Subject: [PATCH 15/42] feat(messaging): single active consumer for cross-instance ordering --- .github/copilot-instructions.md | 1 + docs/articles/messaging.md | 38 +++++- .../PublicAPI.Unshipped.txt | 2 +- .../Publishers/IRequester.cs | 2 +- .../Consumers/MessageTypeCache.cs | 32 ++--- src/Vulthil.Messaging.RabbitMq/RabbitMqBus.cs | 8 ++ .../Requests/PublishContext.cs | 4 +- .../Requests/RabbitMqRequester.cs | 4 +- src/Vulthil.Messaging/PublicAPI.Unshipped.txt | 3 + .../Queues/IQueueConfigurator.cs | 9 ++ .../Queues/QueueConfigurator.cs | 7 ++ .../Queues/QueueDefinition.cs | 8 ++ .../Program.cs | 2 +- .../RabbitMqBusTopologyTests.cs | 114 ++++++++++++++++++ .../RabbitMqRequesterTests.cs | 2 +- .../QueueConfiguratorBuildTests.cs | 28 +++++ 16 files changed, 230 insertions(+), 34 deletions(-) create mode 100644 tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqBusTopologyTests.cs diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 81da157..6bd2e02 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -15,6 +15,7 @@ - Prefer using the Vulthil.xUnit testing framework for tests. - When writing tests, follow the Arrange-Act-Assert pattern for better readability and maintainability. - Prefer using the BaseUnitTestCase or BaseUnitTestCase classes for test cases to leverage common setup and utilities. +- Prefer using the `CancellationToken` property on the base test classes, instead of getting the `TestContext.Current.CancellationToken` directly. - Prefer using the AutoMocker instance for dependency injection in tests to simplify test setup and improve readability. - Use the methods on the BaseUnitTestCase class for modifying the AutoMocker instance, such as `Use(T instance)` or `Use()` for registering dependencies, and `GetMock()` for retrieving mocks from the AutoMocker. - Override the CreateInstance or CreateInstance methods and use the Target property to lazily create the instance under test. diff --git a/docs/articles/messaging.md b/docs/articles/messaging.md index 50125cf..de2b67a 100644 --- a/docs/articles/messaging.md +++ b/docs/articles/messaging.md @@ -339,13 +339,45 @@ Notes: - The partition count affects only fan-out (how many distinct keys progress at once), never correctness. The lane hash is in-process, so a key's lane need not be stable across processes. -- This preserves order on a **single instance**. Ordering across load-balanced consumers - additionally requires a single active consumer per partition (a later enhancement), - mirroring MassTransit's model. +- The partitioner orders deliveries **within one process**. Ordering across load-balanced + consumer instances additionally requires a single active consumer (see *Ordering across + instances* below), which partitioned queues enable automatically. - **Failure path:** a partitioned queue retries **in-memory** automatically — the consumer is re-invoked in-process while the delivery (and its lane) is held — so a failing message cannot be overtaken by a later same-key message. See *Retries* below. +### Ordering across instances (single active consumer) + +The partitioner serializes same-key deliveries inside a single process. When the same queue is +consumed by several load-balanced instances, the broker round-robins deliveries between them and +same-key messages can again be processed concurrently. RabbitMQ's **single active consumer** +closes that gap: the broker keeps exactly one consumer active and promotes a standby consumer only +if the active one disconnects, so ordering is preserved and the queue fails over without manual +intervention. + +Partitioned queues turn this on automatically. Any queue can opt in explicitly: + +```csharp +m.ConfigureQueue("orders", q => +{ + q.UseSingleActiveConsumer(); + q.AddConsumer(); +}); +``` + +Notes and trade-offs: + +- **No scale-out for that queue.** Only one consumer works at a time, so adding instances buys + failover, not throughput. To scale a partitioned workload across instances, shard into multiple + queues (one per partition) and bind each instance to its own — a larger change that is out of + scope here. This mirrors MassTransit, whose in-process partitioner is likewise single-instance. +- **Existing queues.** The single-active-consumer flag is a queue argument fixed at declaration. + Enabling it on a queue that already exists fails declaration with `406 PRECONDITION_FAILED`; + delete and recreate the queue to change it. +- **At-least-once on failover.** When the active consumer dies mid-delivery, unacknowledged + messages are redelivered to the promoted consumer, so a handler may observe a message more than + once. Make handlers idempotent; broker-level exactly-once delivery is not provided. + ## Retries `q.UseRetry(...)` configures the retry policy for a queue's consumers. There are two diff --git a/src/Vulthil.Messaging.Abstractions/PublicAPI.Unshipped.txt b/src/Vulthil.Messaging.Abstractions/PublicAPI.Unshipped.txt index 8ce51eb..3eecc88 100644 --- a/src/Vulthil.Messaging.Abstractions/PublicAPI.Unshipped.txt +++ b/src/Vulthil.Messaging.Abstractions/PublicAPI.Unshipped.txt @@ -63,7 +63,7 @@ Vulthil.Messaging.Abstractions.Publishers.IPublisher.PublishAsync(TMes Vulthil.Messaging.Abstractions.Publishers.IRequestContext Vulthil.Messaging.Abstractions.Publishers.IRequestContext.SetTimeout(System.TimeSpan timeout) -> Vulthil.Messaging.Abstractions.Publishers.IRequestContext! Vulthil.Messaging.Abstractions.Publishers.IRequester -Vulthil.Messaging.Abstractions.Publishers.IRequester.RequestAsync(TRequest message, System.Func? configureContext = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +Vulthil.Messaging.Abstractions.Publishers.IRequester.RequestAsync(TRequest message, System.Func? configureContext = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! Vulthil.Messaging.Abstractions.Publishers.IRequester.RequestAsync(TRequest message, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task!>! Vulthil.Messaging.Abstractions.Publishers.ISendEndpoint Vulthil.Messaging.Abstractions.Publishers.ISendEndpoint.Address.get -> System.Uri! diff --git a/src/Vulthil.Messaging.Abstractions/Publishers/IRequester.cs b/src/Vulthil.Messaging.Abstractions/Publishers/IRequester.cs index d661664..972c36d 100644 --- a/src/Vulthil.Messaging.Abstractions/Publishers/IRequester.cs +++ b/src/Vulthil.Messaging.Abstractions/Publishers/IRequester.cs @@ -32,7 +32,7 @@ Task> RequestAsync( /// A containing the response on success or an error on failure. Task> RequestAsync( TRequest message, - Func? configureContext = null, + Func? configureContext = null, CancellationToken cancellationToken = default) where TRequest : notnull where TResponse : notnull; diff --git a/src/Vulthil.Messaging.RabbitMq/Consumers/MessageTypeCache.cs b/src/Vulthil.Messaging.RabbitMq/Consumers/MessageTypeCache.cs index 23ea093..8228cb8 100644 --- a/src/Vulthil.Messaging.RabbitMq/Consumers/MessageTypeCache.cs +++ b/src/Vulthil.Messaging.RabbitMq/Consumers/MessageTypeCache.cs @@ -102,31 +102,17 @@ private MessageExecutionPlan GetOrAddPlan(MessageType messageType) } /// - /// Indicates whether any concrete message type consumed by is partitioned. - /// Call after so the plans exist. Drives ordered single dispatch on the queue. + /// Indicates whether any concrete message type subscribed or consumed by is + /// partitioned, read directly from . Because it + /// does not depend on built plans it is valid both during topology setup (which precedes + /// ) and afterwards. Drives ordered single dispatch and the single-active-consumer + /// queue argument. /// public bool IsQueuePartitioned(QueueDefinition queue) - { - foreach (var subscription in queue.Subscriptions) - { - if (GetPlanByUrn(_provider.GetUrn(subscription.MessageType.Type))?.IsPartitioned == true) - { - return true; - } - } - - foreach (var registration in queue.Registrations) - { - var type = registration.MessageType.Type; - if (type is { IsAbstract: false, IsInterface: false } - && GetPlanByUrn(_provider.GetUrn(type))?.IsPartitioned == true) - { - return true; - } - } - - return false; - } + => queue.Subscriptions.Any(s => _provider.GetPartition(s.MessageType.Type) is not null) + || queue.Registrations.Any(r => + r.MessageType.Type is { IsAbstract: false, IsInterface: false } + && _provider.GetPartition(r.MessageType.Type) is not null); /// /// Resolves a plan from the wire URN (envelope path). Returns when no plan matches. diff --git a/src/Vulthil.Messaging.RabbitMq/RabbitMqBus.cs b/src/Vulthil.Messaging.RabbitMq/RabbitMqBus.cs index 24740bd..d5a8a2e 100644 --- a/src/Vulthil.Messaging.RabbitMq/RabbitMqBus.cs +++ b/src/Vulthil.Messaging.RabbitMq/RabbitMqBus.cs @@ -125,6 +125,14 @@ await channel.ExchangeDeclareAsync( args.Add("x-queue-type", "quorum"); } + // A partitioned queue's per-key order only holds within one process; a single active consumer keeps a + // single instance active (others stand by for failover) so ordering survives across load-balanced + // consumers. Partitioned queues opt in automatically; any queue can request it explicitly. + if (queue.SingleActiveConsumer || _typeCache.IsQueuePartitioned(queue)) + { + args.Add("x-single-active-consumer", true); + } + if (queue.DeadLetter is { Enabled: true }) { var dlx = queue.DeadLetter.ExchangeName ?? $"{queue.Name}.Error"; diff --git a/src/Vulthil.Messaging.RabbitMq/Requests/PublishContext.cs b/src/Vulthil.Messaging.RabbitMq/Requests/PublishContext.cs index b5fb20b..83eb5e8 100644 --- a/src/Vulthil.Messaging.RabbitMq/Requests/PublishContext.cs +++ b/src/Vulthil.Messaging.RabbitMq/Requests/PublishContext.cs @@ -5,8 +5,8 @@ namespace Vulthil.Messaging.RabbitMq.Requests; internal class PublishContext : IPublishContext { internal Dictionary Headers { get; } = []; - internal string? RoutingKey { get; private set; } - internal string? CorrelationId { get; private set; } + public string? RoutingKey { get; private set; } + public string? CorrelationId { get; private set; } public string? MessageId { get; set; } public string? ConversationId { get => Headers.TryGetValue("ConversationId", out var value) && value is string conversationId ? conversationId : null; set => Headers["ConversationId"] = value; } public string? InitiatorId { get => Headers.TryGetValue("InitiatorId", out var value) && value is string initiatorId ? initiatorId : null; set => Headers["InitiatorId"] = value; } diff --git a/src/Vulthil.Messaging.RabbitMq/Requests/RabbitMqRequester.cs b/src/Vulthil.Messaging.RabbitMq/Requests/RabbitMqRequester.cs index 5a4468a..98b3fa9 100644 --- a/src/Vulthil.Messaging.RabbitMq/Requests/RabbitMqRequester.cs +++ b/src/Vulthil.Messaging.RabbitMq/Requests/RabbitMqRequester.cs @@ -41,14 +41,14 @@ public Task> RequestAsync( public async Task> RequestAsync( TRequest message, - Func? configureContext = null, + Func? configureContext = null, CancellationToken cancellationToken = default) where TRequest : notnull where TResponse : notnull { ArgumentNullException.ThrowIfNull(message); var requestContext = new RequestContext(); - configureContext ??= (_ => Task.CompletedTask); + configureContext ??= (_ => ValueTask.CompletedTask); await configureContext(requestContext); var tcs = new TaskCompletionSource>(TaskCreationOptions.RunContinuationsAsynchronously); diff --git a/src/Vulthil.Messaging/PublicAPI.Unshipped.txt b/src/Vulthil.Messaging/PublicAPI.Unshipped.txt index 6f10cc2..48699ea 100644 --- a/src/Vulthil.Messaging/PublicAPI.Unshipped.txt +++ b/src/Vulthil.Messaging/PublicAPI.Unshipped.txt @@ -97,6 +97,7 @@ Vulthil.Messaging.Queues.IQueueConfigurator.Subscribe(string? routingK Vulthil.Messaging.Queues.IQueueConfigurator.SubscribeAll(System.Reflection.Assembly! assembly, string? routingKey = null) -> Vulthil.Messaging.Queues.IQueueConfigurator! Vulthil.Messaging.Queues.IQueueConfigurator.UseDeadLetterQueue(string? queueName = null, string? exchangeName = null) -> Vulthil.Messaging.Queues.IQueueConfigurator! Vulthil.Messaging.Queues.IQueueConfigurator.UseRetry(System.Action! configure) -> Vulthil.Messaging.Queues.IQueueConfigurator! +Vulthil.Messaging.Queues.IQueueConfigurator.UseSingleActiveConsumer() -> Vulthil.Messaging.Queues.IQueueConfigurator! Vulthil.Messaging.Queues.IRequestConfigurator Vulthil.Messaging.Queues.MessageType Vulthil.Messaging.Queues.MessageType.MessageType(System.Type! Type) -> void @@ -133,6 +134,8 @@ Vulthil.Messaging.Queues.QueueDefinition.PrefetchCount.get -> ushort Vulthil.Messaging.Queues.QueueDefinition.PrefetchCount.set -> void Vulthil.Messaging.Queues.QueueDefinition.QueueDefinition(string! Name) -> void Vulthil.Messaging.Queues.QueueDefinition.Registrations.get -> System.Collections.Generic.IReadOnlyCollection! +Vulthil.Messaging.Queues.QueueDefinition.SingleActiveConsumer.get -> bool +Vulthil.Messaging.Queues.QueueDefinition.SingleActiveConsumer.set -> void Vulthil.Messaging.Queues.QueueDefinition.Subscriptions.get -> System.Collections.Generic.IReadOnlyCollection! Vulthil.Messaging.Queues.Subscription Vulthil.Messaging.Queues.Subscription.MessageType.get -> Vulthil.Messaging.Queues.MessageType! diff --git a/src/Vulthil.Messaging/Queues/IQueueConfigurator.cs b/src/Vulthil.Messaging/Queues/IQueueConfigurator.cs index 319ff89..cf1654d 100644 --- a/src/Vulthil.Messaging/Queues/IQueueConfigurator.cs +++ b/src/Vulthil.Messaging/Queues/IQueueConfigurator.cs @@ -63,6 +63,15 @@ public interface IQueueConfigurator /// Optional dead letter queue name. /// Optional dead letter exchange name. IQueueConfigurator UseDeadLetterQueue(string? queueName = null, string? exchangeName = null); + + /// + /// Declares the queue with RabbitMQ's single active consumer feature: only one consumer is active at a + /// time while additional consumers stand by and take over on failure. This preserves per-queue order + /// across load-balanced consumer instances — extending the in-process partitioner's ordering guarantee + /// to multiple instances — at the cost of throughput scale-out for the queue. Partitioned queues enable + /// this automatically. + /// + IQueueConfigurator UseSingleActiveConsumer(); } /// diff --git a/src/Vulthil.Messaging/Queues/QueueConfigurator.cs b/src/Vulthil.Messaging/Queues/QueueConfigurator.cs index 7c62758..93c6d32 100644 --- a/src/Vulthil.Messaging/Queues/QueueConfigurator.cs +++ b/src/Vulthil.Messaging/Queues/QueueConfigurator.cs @@ -141,6 +141,13 @@ public IQueueConfigurator UseDeadLetterQueue(string? queueName = null, string? e return this; } + /// + public IQueueConfigurator UseSingleActiveConsumer() + { + _queueDefinition.SingleActiveConsumer = true; + return this; + } + /// /// Final resolution pass — runs once after the user's configurator action completes. Auto-subscribes /// any concrete TMessage from consumer registrations that wasn't explicitly subscribed, and validates diff --git a/src/Vulthil.Messaging/Queues/QueueDefinition.cs b/src/Vulthil.Messaging/Queues/QueueDefinition.cs index 4243a7b..90f350d 100644 --- a/src/Vulthil.Messaging/Queues/QueueDefinition.cs +++ b/src/Vulthil.Messaging/Queues/QueueDefinition.cs @@ -125,6 +125,14 @@ public sealed record QueueDefinition(string Name) /// Gets or sets a value indicating whether this queue is exclusive to the declaring connection. /// public bool Exclusive { get; set; } + /// + /// Gets or sets a value indicating whether the queue is declared with RabbitMQ's single active consumer + /// feature, so that only one consumer processes deliveries at a time (additional consumers stand by and + /// take over on failure). This preserves per-queue order across load-balanced consumer instances at the + /// cost of throughput scale-out for the queue. Partitioned queues enable this automatically. + /// Default is . + /// + public bool SingleActiveConsumer { get; set; } /// /// Gets or sets the exchange type for the queue's exchange binding. Default is . diff --git a/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ProducerService/Program.cs b/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ProducerService/Program.cs index 4e16940..72f777c 100644 --- a/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ProducerService/Program.cs +++ b/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.ProducerService/Program.cs @@ -118,7 +118,7 @@ context => { context.SetTimeout(TimeSpan.FromSeconds(2)); - return Task.CompletedTask; + return ValueTask.CompletedTask; }, cancellationToken); diff --git a/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqBusTopologyTests.cs b/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqBusTopologyTests.cs new file mode 100644 index 0000000..b7ec68d --- /dev/null +++ b/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqBusTopologyTests.cs @@ -0,0 +1,114 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using RabbitMQ.Client; +using Vulthil.Messaging.Abstractions.Consumers; +using Vulthil.Messaging.Queues; +using Vulthil.Messaging.RabbitMq.HealthChecks; +using Vulthil.xUnit; + +namespace Vulthil.Messaging.RabbitMq.Tests; + +public sealed class RabbitMqBusTopologyTests : BaseUnitTestCase +{ + private const string SingleActiveConsumerArgument = "x-single-active-consumer"; + + private readonly Dictionary> _declaredQueues = new(StringComparer.Ordinal); + + public RabbitMqBusTopologyTests() + { + var channel = GetMock(); + channel + .Setup(c => c.QueueDeclareAsync( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((string queue, bool _, bool _, bool _, IDictionary arguments, bool _, bool _, CancellationToken _) => + _declaredQueues[queue] = arguments) + .ReturnsAsync(new QueueDeclareOk("queue", 0, 0)); + channel + .Setup(c => c.BasicConsumeAsync( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny>(), It.IsAny(), It.IsAny())) + .ReturnsAsync("consumer-tag"); + + GetMock() + .Setup(c => c.CreateChannelAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(channel.Object); + + Use(new RabbitMqBusStartupStatus()); + Use(NullLoggerFactory.Instance); + Use>(NullLogger.Instance); + } + + private async Task DeclareTopologyAsync(MessagingOptions options) + { + Use(options); + await using var bus = CreateInstance(); + await bus.StartAsync(CancellationToken); + } + + private static MessagingOptions OptionsConsumingOrderedEvents(string queueName) + { + var queue = new QueueDefinition(queueName); + queue.AddConsumer(new ConsumerRegistration + { + ConsumerType = new ConsumerType(typeof(OrderedConsumer)), + MessageType = new MessageType(typeof(OrderedMessage)), + }); + + var options = new MessagingOptions(); + options.QueueDefinitions[queue.Name] = queue; + return options; + } + + [Fact] + public async Task PlainQueueIsDeclaredWithoutSingleActiveConsumerArgument() + { + // Arrange + var options = OptionsConsumingOrderedEvents("plain"); + + // Act + await DeclareTopologyAsync(options); + + // Assert + _declaredQueues.ShouldContainKey("plain"); + _declaredQueues["plain"].ShouldNotContainKey(SingleActiveConsumerArgument); + } + + [Fact] + public async Task ExplicitlyConfiguredQueueIsDeclaredWithSingleActiveConsumerArgument() + { + // Arrange + var options = OptionsConsumingOrderedEvents("sole"); + options.QueueDefinitions["sole"].SingleActiveConsumer = true; + + // Act + await DeclareTopologyAsync(options); + + // Assert + _declaredQueues["sole"][SingleActiveConsumerArgument].ShouldBe(true); + } + + [Fact] + public async Task PartitionedQueueAutomaticallyEnablesSingleActiveConsumer() + { + // Arrange + var options = OptionsConsumingOrderedEvents("ordered"); + options.RegisterPartition( + typeof(OrderedMessage), + new PartitionSpec(new Partitioner(4), (Func, string?>)(context => context.CorrelationId))); + + // Act + await DeclareTopologyAsync(options); + + // Assert + _declaredQueues["ordered"][SingleActiveConsumerArgument].ShouldBe(true); + } + + internal sealed record OrderedMessage(string Key); + + private sealed class OrderedConsumer : IConsumer + { + public Task ConsumeAsync(IMessageContext messageContext, CancellationToken cancellationToken = default) + => Task.CompletedTask; + } +} diff --git a/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqRequesterTests.cs b/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqRequesterTests.cs index ad3e66c..afeba6f 100644 --- a/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqRequesterTests.cs +++ b/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqRequesterTests.cs @@ -55,7 +55,7 @@ public async Task RequestAsyncReturnsTimeoutFailureWhenNoResponseArrivesWithinPe context => { context.SetTimeout(TimeSpan.FromMilliseconds(200)); - return Task.CompletedTask; + return ValueTask.CompletedTask; }, CancellationToken); stopwatch.Stop(); diff --git a/tests/Vulthil.Messaging.Tests/QueueConfiguratorBuildTests.cs b/tests/Vulthil.Messaging.Tests/QueueConfiguratorBuildTests.cs index 70f6798..495fbe6 100644 --- a/tests/Vulthil.Messaging.Tests/QueueConfiguratorBuildTests.cs +++ b/tests/Vulthil.Messaging.Tests/QueueConfiguratorBuildTests.cs @@ -114,6 +114,34 @@ public void BuildThrowsWhenRequestConsumerTargetsPolymorphicType() ex.Message.ShouldContain("polymorphic request type"); } + + [Fact] + public void UseSingleActiveConsumerEnablesTheFlagOnTheQueue() + { + // Act + Target.AddMessaging(m => m.ConfigureQueue("orders", q => + { + q.UseSingleActiveConsumer(); + q.AddConsumer(); + })); + + // Assert + var queue = GetQueue(Target, "orders"); + queue.ShouldNotBeNull(); + queue.SingleActiveConsumer.ShouldBeTrue(); + } + + [Fact] + public void QueueDoesNotEnableSingleActiveConsumerByDefault() + { + // Act + Target.AddMessaging(m => m.ConfigureQueue("orders", q => q.AddConsumer())); + + // Assert + var queue = GetQueue(Target, "orders"); + queue.ShouldNotBeNull(); + queue.SingleActiveConsumer.ShouldBeFalse(); + } } internal interface IOrderEvent { } From e47519a694bc11ae8e6265042cc614c0ab3f1061 Mon Sep 17 00:00:00 2001 From: Vulthil Date: Mon, 1 Jun 2026 20:37:31 +0200 Subject: [PATCH 16/42] feat(messaging): add CorrelationId-keyed UsePartitioner overloads --- docs/articles/messaging.md | 12 +++--- .../IMessagingConfigurator.cs | 24 +++++++++++ .../MessagingConfigurator.cs | 8 ++++ src/Vulthil.Messaging/PublicAPI.Unshipped.txt | 2 + .../PartitionerTests.cs | 43 ++++++++++++++++++- 5 files changed, 83 insertions(+), 6 deletions(-) diff --git a/docs/articles/messaging.md b/docs/articles/messaging.md index de2b67a..13b75e2 100644 --- a/docs/articles/messaging.md +++ b/docs/articles/messaging.md @@ -301,20 +301,22 @@ builder.AddMessaging(m => // Order OrderUpdated deliveries per OrderId across 16 lanes. m.UsePartitioner(partitionCount: 16, ctx => ctx.Message.OrderId.ToString()); - // The CorrelationId is the natural key when it carries the aggregate id. - m.UsePartitioner(16, ctx => ctx.CorrelationId); + // Shorthand: omit the selector to key on CorrelationId (the natural key when it + // carries the aggregate id). Equivalent to passing ctx => ctx.CorrelationId. + m.UsePartitioner(16); m.ConfigureQueue("orders", q => q.AddConsumer()); }); ``` Share one `Partitioner` across several message types to serialize messages correlated -to the same key regardless of their type (e.g. a saga): +to the same key regardless of their type (e.g. a saga). The selector is optional here too +and defaults to `CorrelationId`: ```csharp var orders = new Partitioner(16); -m.UsePartitioner(orders, ctx => ctx.CorrelationId); -m.UsePartitioner(orders, ctx => ctx.CorrelationId); +m.UsePartitioner(orders); +m.UsePartitioner(orders); ``` ### How it works diff --git a/src/Vulthil.Messaging/IMessagingConfigurator.cs b/src/Vulthil.Messaging/IMessagingConfigurator.cs index f8640d7..cc6e760 100644 --- a/src/Vulthil.Messaging/IMessagingConfigurator.cs +++ b/src/Vulthil.Messaging/IMessagingConfigurator.cs @@ -71,6 +71,17 @@ IMessagingConfigurator ConfigureMessage(Action(int partitionCount, Func, string?> keySelector) where TMessage : notnull; + /// + /// Serializes processing of deliveries by their CorrelationId — + /// shorthand for the overload taking an explicit key selector with ctx => ctx.CorrelationId. + /// Deliveries with no correlation id bypass the partitioner. + /// + /// The message type to partition. + /// The number of partitions (lanes) to distribute keys across. + /// The current configurator instance for chaining. + IMessagingConfigurator UsePartitioner(int partitionCount) + where TMessage : notnull; + /// /// Serializes processing of deliveries that share a partition key using a /// caller-supplied . Share one across several message @@ -85,4 +96,17 @@ IMessagingConfigurator UsePartitioner(int partitionCount, FuncThe current configurator instance for chaining. IMessagingConfigurator UsePartitioner(Partitioner partitioner, Func, string?> keySelector) where TMessage : notnull; + + /// + /// Serializes processing of deliveries by their CorrelationId using a + /// caller-supplied — shorthand for the overload taking an explicit key selector + /// with ctx => ctx.CorrelationId. Share one across several message types + /// to serialize messages correlated to the same id regardless of their type (e.g. a saga). Deliveries with + /// no correlation id bypass the partitioner. + /// + /// The message type to partition. + /// The partitioner whose lanes serialize same-key processing. + /// The current configurator instance for chaining. + IMessagingConfigurator UsePartitioner(Partitioner partitioner) + where TMessage : notnull; } diff --git a/src/Vulthil.Messaging/MessagingConfigurator.cs b/src/Vulthil.Messaging/MessagingConfigurator.cs index 0d037c0..14a9cc7 100644 --- a/src/Vulthil.Messaging/MessagingConfigurator.cs +++ b/src/Vulthil.Messaging/MessagingConfigurator.cs @@ -157,6 +157,10 @@ public IMessagingConfigurator UsePartitioner(int partitionCount, Func< where TMessage : notnull => UsePartitioner(new Partitioner(partitionCount), keySelector); + public IMessagingConfigurator UsePartitioner(int partitionCount) + where TMessage : notnull + => UsePartitioner(partitionCount, static context => context.CorrelationId); + public IMessagingConfigurator UsePartitioner(Partitioner partitioner, Func, string?> keySelector) where TMessage : notnull { @@ -168,4 +172,8 @@ public IMessagingConfigurator UsePartitioner(Partitioner partitioner, _messagingOptions.RegisterPartition(typeof(TMessage), new PartitionSpec(partitioner, keySelector)); return this; } + + public IMessagingConfigurator UsePartitioner(Partitioner partitioner) + where TMessage : notnull + => UsePartitioner(partitioner, static context => context.CorrelationId); } diff --git a/src/Vulthil.Messaging/PublicAPI.Unshipped.txt b/src/Vulthil.Messaging/PublicAPI.Unshipped.txt index 48699ea..bae7dca 100644 --- a/src/Vulthil.Messaging/PublicAPI.Unshipped.txt +++ b/src/Vulthil.Messaging/PublicAPI.Unshipped.txt @@ -183,4 +183,6 @@ Vulthil.Messaging.Partitioner Vulthil.Messaging.Partitioner.Partitioner(int partitionCount) -> void Vulthil.Messaging.Partitioner.PartitionCount.get -> int Vulthil.Messaging.IMessagingConfigurator.UsePartitioner(int partitionCount, System.Func!, string?>! keySelector) -> Vulthil.Messaging.IMessagingConfigurator! +Vulthil.Messaging.IMessagingConfigurator.UsePartitioner(int partitionCount) -> Vulthil.Messaging.IMessagingConfigurator! Vulthil.Messaging.IMessagingConfigurator.UsePartitioner(Vulthil.Messaging.Partitioner! partitioner, System.Func!, string?>! keySelector) -> Vulthil.Messaging.IMessagingConfigurator! +Vulthil.Messaging.IMessagingConfigurator.UsePartitioner(Vulthil.Messaging.Partitioner! partitioner) -> Vulthil.Messaging.IMessagingConfigurator! diff --git a/tests/Vulthil.Messaging.Tests/PartitionerTests.cs b/tests/Vulthil.Messaging.Tests/PartitionerTests.cs index 1138e48..70e3e89 100644 --- a/tests/Vulthil.Messaging.Tests/PartitionerTests.cs +++ b/tests/Vulthil.Messaging.Tests/PartitionerTests.cs @@ -1,5 +1,6 @@ using System.Collections.Concurrent; using Microsoft.Extensions.Hosting; +using Vulthil.Messaging.Abstractions.Consumers; using Vulthil.xUnit; namespace Vulthil.Messaging.Tests; @@ -103,6 +104,46 @@ public void UsePartitionerRegistersThePartitionForTheMessageType() options.GetPartition(typeof(PartitionTestMessage)).ShouldNotBeNull(); } + [Fact] + public void UsePartitionerWithCountDefaultsTheKeyToCorrelationId() + { + // Arrange + var options = new MessagingOptions(); + var configurator = new MessagingConfigurator(Host.CreateApplicationBuilder(), options); + var context = new Mock>(); + context.SetupGet(c => c.CorrelationId).Returns("corr-1"); + + // Act + configurator.UsePartitioner(8); + + // Assert + var spec = options.GetPartition(typeof(PartitionTestMessage)); + spec.ShouldNotBeNull(); + var selector = spec.KeySelector.ShouldBeAssignableTo, string?>>(); + selector(context.Object).ShouldBe("corr-1"); + } + + [Fact] + public void UsePartitionerWithSharedPartitionerDefaultsTheKeyToCorrelationId() + { + // Arrange + var options = new MessagingOptions(); + var configurator = new MessagingConfigurator(Host.CreateApplicationBuilder(), options); + var partitioner = new Partitioner(4); + var context = new Mock>(); + context.SetupGet(c => c.CorrelationId).Returns("corr-2"); + + // Act + configurator.UsePartitioner(partitioner); + + // Assert + var spec = options.GetPartition(typeof(PartitionTestMessage)); + spec.ShouldNotBeNull(); + spec.Partitioner.ShouldBeSameAs(partitioner); + var selector = spec.KeySelector.ShouldBeAssignableTo, string?>>(); + selector(context.Object).ShouldBe("corr-2"); + } + private static (string KeyA, string KeyB) FindKeysOnDifferentLanes(Partitioner partitioner) { const string first = "key-0"; @@ -119,5 +160,5 @@ private static (string KeyA, string KeyB) FindKeysOnDifferentLanes(Partitioner p throw new InvalidOperationException("Could not find two keys mapping to different lanes."); } - private sealed record PartitionTestMessage(string Value); + internal sealed record PartitionTestMessage(string Value); } From 4d5ed3a971fb3cb6969a91b447859910ce823ba8 Mon Sep 17 00:00:00 2001 From: Vulthil Date: Mon, 1 Jun 2026 22:44:06 +0200 Subject: [PATCH 17/42] fix(messaging): correlate RPC on RequestId, envelope replies, enable publisher confirms --- docs/articles/messaging.md | 21 ++++++ .../Consumers/MessageHandlerFactory.cs | 65 ++++++++++++++----- .../Envelope/MessageEnvelopeFactory.cs | 4 +- .../Publishing/RabbitMqPublisher.cs | 22 +++++-- .../Requests/MessageResult.cs | 63 ------------------ .../Requests/RabbitMqRequester.cs | 14 ++-- .../Requests/ResponseListener.cs | 6 +- .../Requests/ResponseWaiter.cs | 41 +++++++----- .../Requests/RpcFault.cs | 33 ++++++++++ .../ConsumeFilterPipelineTests.cs | 13 ++-- .../MessageTypeCacheTests.cs | 22 ++++--- .../RabbitMqPublisherExtendedTests.cs | 15 +++++ .../RabbitMqPublisherTests.cs | 4 +- .../RabbitMqRequesterTests.cs | 43 ++++++++++++ 14 files changed, 241 insertions(+), 125 deletions(-) delete mode 100644 src/Vulthil.Messaging.RabbitMq/Requests/MessageResult.cs create mode 100644 src/Vulthil.Messaging.RabbitMq/Requests/RpcFault.cs diff --git a/docs/articles/messaging.md b/docs/articles/messaging.md index 13b75e2..8e13449 100644 --- a/docs/articles/messaging.md +++ b/docs/articles/messaging.md @@ -90,6 +90,12 @@ public sealed class PlaceOrderHandler(IPublisher publisher) } ``` +> **Delivery guarantees.** Publishing uses RabbitMQ publisher confirms: the call awaits the +> broker's acknowledgement and throws if the message is nacked, so a publish the broker never +> accepted does not report success. `Publish` (pub/sub over a fanout/topic exchange) is *not* +> mandatory — zero subscribers is normal — whereas `Send` (point-to-point) *is* mandatory, so a +> missing destination queue surfaces as a failure rather than being silently dropped. + ### Publishing from inside a consumer `IMessageContext` exposes `PublishAsync` directly, so consumers can emit follow-up @@ -725,6 +731,21 @@ When no timeout is set on the context, the request falls back to its timeout completes with a `Result` failure carrying the `Messaging.Request.Timeout` error code rather than throwing. +### Reply wire format & correlation + +Each request carries a dedicated **request id** (a fresh GUID per call) in the AMQP +`CorrelationId` property and the envelope's `requestId` field. The reply echoes it, and the +requester correlates the reply to the awaiting call by this id — independently of the business +`CorrelationId` (set via `UseCorrelationId` or `SetCorrelationId`), which is therefore free to +repeat across concurrent requests without colliding. + +The reply is a normal `MessageEnvelope` (single-serialized, like every other message): + +- **Success** carries the `TResponse` payload at the response type's URN. +- **Failure** carries an RPC fault at `urn:message:Vulthil:RpcFault` (the remote exception's + type and message); the requester maps it to a `Result` failure with the + `Messaging.Request.Failure` error code. + ## Testing Messaging `Vulthil.Messaging.TestHarness` provides an in-memory transport that captures diff --git a/src/Vulthil.Messaging.RabbitMq/Consumers/MessageHandlerFactory.cs b/src/Vulthil.Messaging.RabbitMq/Consumers/MessageHandlerFactory.cs index 4c15538..e25eeb1 100644 --- a/src/Vulthil.Messaging.RabbitMq/Consumers/MessageHandlerFactory.cs +++ b/src/Vulthil.Messaging.RabbitMq/Consumers/MessageHandlerFactory.cs @@ -60,12 +60,13 @@ public static MessageHandler ForRequestConsumer( var consumer = sp.GetRequiredService(); var publisher = sp.GetRequiredService(); var sendEndpointProvider = sp.GetRequiredService(); - var jsonOptions = sp.GetRequiredService().JsonSerializerOptions; + var provider = sp.GetRequiredService(); + var jsonOptions = provider.JsonSerializerOptions; var context = envelope is null ? MessageContext.CreateContext((TRequest)message, ea, publisher, sendEndpointProvider, ct) : MessageContext.CreateContext((TRequest)message, ea, envelope, publisher, sendEndpointProvider, ct); - MessageResult messageResult; + MessageEnvelope reply; try { // The terminal stage captures the consumer's response so any wrapping filters can @@ -83,39 +84,69 @@ public static MessageHandler ForRequestConsumer( await pipeline(context); - if (!responseProduced) - { - messageResult = MessageResult.Failure("Consume pipeline did not produce a response (a filter likely short-circuited the chain)."); - } - else - { - var responseBytes = JsonSerializer.SerializeToUtf8Bytes(response, jsonOptions); - messageResult = MessageResult.Success(responseBytes); - } + reply = responseProduced + ? BuildReply(provider.GetUrn(typeof(TResponse)), JsonSerializer.SerializeToElement(response, jsonOptions), ea, envelope) + : BuildFaultReply( + "Consume pipeline did not produce a response (a filter likely short-circuited the chain).", + typeof(InvalidOperationException).FullName!, + stackTrace: null, + jsonOptions, + ea, + envelope); } catch (Exception exception) { - messageResult = MessageResult.Failure(exception.Message); + reply = BuildFaultReply(exception.Message, exception.GetType().FullName ?? "Unknown", exception.StackTrace, jsonOptions, ea, envelope); } - await SendResponseAsync(ea, messageResult, channel, jsonOptions); + await SendResponseAsync(ea, reply, channel, jsonOptions); } }; - private static async Task SendResponseAsync(BasicDeliverEventArgs ea, MessageResult response, IChannel channel, JsonSerializerOptions jsonOptions) + private static MessageEnvelope BuildReply(Uri messageType, JsonElement message, BasicDeliverEventArgs ea, MessageEnvelope? requestEnvelope) + => new() + { + MessageId = Guid.CreateVersion7().ToString(), + RequestId = ea.BasicProperties.CorrelationId, + CorrelationId = requestEnvelope?.CorrelationId, + MessageType = messageType, + Message = message, + SentTime = DateTimeOffset.UtcNow, + }; + + private static MessageEnvelope BuildFaultReply( + string message, + string exceptionType, + string? stackTrace, + JsonSerializerOptions jsonOptions, + BasicDeliverEventArgs ea, + MessageEnvelope? requestEnvelope) + { + var fault = new RpcFault + { + Message = message, + ExceptionType = exceptionType, + StackTrace = stackTrace, + FaultedAt = DateTimeOffset.UtcNow, + }; + return BuildReply(RpcFault.UrnUri, JsonSerializer.SerializeToElement(fault, jsonOptions), ea, requestEnvelope); + } + + private static async Task SendResponseAsync(BasicDeliverEventArgs ea, MessageEnvelope reply, IChannel channel, JsonSerializerOptions jsonOptions) { if (string.IsNullOrEmpty(ea.BasicProperties.ReplyTo)) { return; } - var responseBytes = JsonSerializer.SerializeToUtf8Bytes(response, jsonOptions); + var body = JsonSerializer.SerializeToUtf8Bytes(reply, jsonOptions); var replyProps = new BasicProperties { CorrelationId = ea.BasicProperties.CorrelationId, - Type = response.GetType().FullName + Type = reply.MessageType.AbsoluteUri, + ContentType = RabbitMqConstants.ContentType, }; - await channel.BasicPublishAsync(string.Empty, ea.BasicProperties.ReplyTo, true, replyProps, responseBytes); + await channel.BasicPublishAsync(string.Empty, ea.BasicProperties.ReplyTo, true, replyProps, body); } } diff --git a/src/Vulthil.Messaging.RabbitMq/Envelope/MessageEnvelopeFactory.cs b/src/Vulthil.Messaging.RabbitMq/Envelope/MessageEnvelopeFactory.cs index f9c0e5f..536a80a 100644 --- a/src/Vulthil.Messaging.RabbitMq/Envelope/MessageEnvelopeFactory.cs +++ b/src/Vulthil.Messaging.RabbitMq/Envelope/MessageEnvelopeFactory.cs @@ -24,7 +24,8 @@ public static MessageEnvelope Create( string messageId, string correlationId, Uri urn, - JsonSerializerOptions jsonOptions) + JsonSerializerOptions jsonOptions, + string? requestId = null) where TMessage : notnull { // Copy user headers, removing the keys that we promote to typed envelope fields. @@ -42,6 +43,7 @@ public static MessageEnvelope Create( return new MessageEnvelope { MessageId = messageId, + RequestId = requestId, CorrelationId = correlationId, ConversationId = publishContext.ConversationId, InitiatorId = publishContext.InitiatorId, diff --git a/src/Vulthil.Messaging.RabbitMq/Publishing/RabbitMqPublisher.cs b/src/Vulthil.Messaging.RabbitMq/Publishing/RabbitMqPublisher.cs index cb004eb..30fb5cf 100644 --- a/src/Vulthil.Messaging.RabbitMq/Publishing/RabbitMqPublisher.cs +++ b/src/Vulthil.Messaging.RabbitMq/Publishing/RabbitMqPublisher.cs @@ -44,7 +44,9 @@ public async Task InternalPublishAsync( await EnsureChannelAsync(cancellationToken); await EnsureExchangeTopologyAsync(exchange, messageConfiguration, cancellationToken); - await BasicPublishAsync(exchange, routingKey, props, body, cancellationToken); + // Publish is pub/sub over a fanout/topic exchange: zero bound subscribers is normal, so the message + // is not mandatory. Broker confirms still apply (a nack throws), guarding against broker-side loss. + await BasicPublishAsync(exchange, routingKey, props, body, mandatory: false, cancellationToken); } public async Task InternalSendAsync( @@ -55,8 +57,10 @@ public async Task InternalSendAsync( { // Sends route via the broker's default exchange (always exists, always routes by queue name). // The destination queue is owned by the receiving service, so we do not declare it here. + // A send is point-to-point, so a missing destination queue is a real error: publish mandatory so + // the broker returns an unroutable message and the awaited confirm throws PublishReturnException. await EnsureChannelAsync(cancellationToken); - await BasicPublishAsync(exchange: string.Empty, routingKey: queueName, props, body, cancellationToken); + await BasicPublishAsync(exchange: string.Empty, routingKey: queueName, props, body, mandatory: true, cancellationToken); } private async Task BasicPublishAsync( @@ -64,6 +68,7 @@ private async Task BasicPublishAsync( string routingKey, BasicProperties props, byte[] body, + bool mandatory, CancellationToken cancellationToken) { await _channelSemaphore.WaitAsync(cancellationToken); @@ -73,7 +78,7 @@ private async Task BasicPublishAsync( await _channel!.BasicPublishAsync( exchange: exchange, routingKey: routingKey, - mandatory: true, + mandatory: mandatory, basicProperties: props, body: body, cancellationToken: cancellationToken); @@ -213,7 +218,16 @@ private async ValueTask EnsureChannelAsync(CancellationToken cancellationToken) } #pragma warning restore CA1508 - _channel = await _rabbitMqConnection.CreateChannelAsync(cancellationToken: cancellationToken); + // Publisher confirmations (with tracking) make the awaited BasicPublishAsync wait for the broker + // ack and throw on a nack or unroutable-mandatory return, so a publish that the broker never + // accepted no longer reports success. This serializes publishing through the single channel until + // a channel pool is introduced; tune throughput there. + _channel = await _rabbitMqConnection.CreateChannelAsync( + new CreateChannelOptions( + publisherConfirmationsEnabled: true, + publisherConfirmationTrackingEnabled: true, + consumerDispatchConcurrency: 1), + cancellationToken); } finally { diff --git a/src/Vulthil.Messaging.RabbitMq/Requests/MessageResult.cs b/src/Vulthil.Messaging.RabbitMq/Requests/MessageResult.cs deleted file mode 100644 index ca4c227..0000000 --- a/src/Vulthil.Messaging.RabbitMq/Requests/MessageResult.cs +++ /dev/null @@ -1,63 +0,0 @@ -using System.Diagnostics.CodeAnalysis; - -namespace Vulthil.Messaging.RabbitMq.Requests; - -/// -/// Wire-level envelope for an RPC reply: either a success carrying the serialized response payload, -/// or a failure carrying an error message. Written to the reply queue by the request handler and -/// read back by . -/// -internal sealed record MessageResult -{ - /// - /// Gets a value indicating whether the request was handled successfully. When , - /// is non-null; otherwise is non-null. - /// - [MemberNotNullWhen(true, nameof(Value))] - [MemberNotNullWhen(false, nameof(ErrorMessage))] - public bool IsSuccess { get; private set; } - /// - /// Gets the serialized response payload, or when the request failed. - /// - public byte[]? Value { get; private set; } - /// - /// Gets the error description, or when the request succeeded. - /// - public string? ErrorMessage { get; private set; } - - /// - /// Initializes a new , validating that a success carries a - /// and a failure carries an . - /// - /// Whether the request was handled successfully. - /// The serialized response payload; required when is . - /// The error description; required when is . - public MessageResult(bool isSuccess, byte[]? value, string? errorMessage = null) - { - if (isSuccess && value is null) - { - ArgumentNullException.ThrowIfNull(value); - } - else if (!isSuccess && string.IsNullOrWhiteSpace(errorMessage)) - { - ArgumentException.ThrowIfNullOrWhiteSpace(errorMessage); - } - - IsSuccess = isSuccess; - Value = value; - ErrorMessage = errorMessage; - } - - /// - /// Creates a successful result wrapping the serialized response payload. - /// - /// The serialized response payload. - /// A successful . - public static MessageResult Success(byte[] value) => new(true, value, null); - /// - /// Creates a failed result carrying the specified error description. - /// - /// The error description. - /// A failed . - public static MessageResult Failure(string errorMessage) => new(false, null, errorMessage); -} diff --git a/src/Vulthil.Messaging.RabbitMq/Requests/RabbitMqRequester.cs b/src/Vulthil.Messaging.RabbitMq/Requests/RabbitMqRequester.cs index 98b3fa9..65d036d 100644 --- a/src/Vulthil.Messaging.RabbitMq/Requests/RabbitMqRequester.cs +++ b/src/Vulthil.Messaging.RabbitMq/Requests/RabbitMqRequester.cs @@ -68,6 +68,12 @@ public async Task> RequestAsync( ?? messageConfiguration.CorrelationIdFormatter?.Invoke(message) ?? Guid.CreateVersion7().ToString(); + // A dedicated per-request id correlates the reply back to this call. It is carried in the AMQP + // CorrelationId property (the RPC slot the reply echoes) and the envelope's RequestId, leaving the + // business CorrelationId free — two requests sharing a business key no longer collide on the waiter. + var requestId = Guid.CreateVersion7().ToString(); + var responseUrn = _messageConfigurationProvider.GetUrn(typeof(TResponse)); + var messageId = requestContext.MessageId ?? Guid.CreateVersion7().ToString(); var exchange = messageConfiguration.Exchange; var urn = messageConfiguration.Urn; @@ -91,14 +97,14 @@ public async Task> RequestAsync( activity.SetTag(MessagingInstrumentation.Tags.MessagingCorrelationId, correlationId); } - _listener.RegisterWaiter(correlationId, tcs); + _listener.RegisterWaiter(requestId, tcs, responseUrn); MessagingLog.RequestSending(_logger, urnString, correlationId, timeout.TotalSeconds); try { var props = new BasicProperties { - CorrelationId = correlationId, + CorrelationId = requestId, ReplyTo = replyTo, ContentType = RabbitMqConstants.ContentType, Type = urnString, @@ -108,7 +114,7 @@ public async Task> RequestAsync( MessageId = messageId, }; - var envelope = MessageEnvelopeFactory.Create(message, requestContext, messageId, correlationId, urn, JsonOptions); + var envelope = MessageEnvelopeFactory.Create(message, requestContext, messageId, correlationId, urn, JsonOptions, requestId); var body = JsonSerializer.SerializeToUtf8Bytes(envelope, JsonOptions); await _publisher.InternalPublishAsync(body, props, routingKey, messageConfiguration, cancellationToken); @@ -139,7 +145,7 @@ public async Task> RequestAsync( } finally { - _listener.RemoveWaiter(correlationId); + _listener.RemoveWaiter(requestId); } } } diff --git a/src/Vulthil.Messaging.RabbitMq/Requests/ResponseListener.cs b/src/Vulthil.Messaging.RabbitMq/Requests/ResponseListener.cs index 3494e8e..20a88f1 100644 --- a/src/Vulthil.Messaging.RabbitMq/Requests/ResponseListener.cs +++ b/src/Vulthil.Messaging.RabbitMq/Requests/ResponseListener.cs @@ -45,10 +45,10 @@ public async ValueTask GetReplyToQueueNameAsync(CancellationToken cancel return _replyToQueueName; } - public void RegisterWaiter(string correlationId, TaskCompletionSource> tcs) where T : notnull - => _waiters[correlationId] = new ResponseWaiter(tcs, JsonOptions); + public void RegisterWaiter(string requestId, TaskCompletionSource> tcs, Uri responseUrn) where T : notnull + => _waiters[requestId] = new ResponseWaiter(tcs, JsonOptions, responseUrn); - public void RemoveWaiter(string correlationId) => _waiters.TryRemove(correlationId, out _); + public void RemoveWaiter(string requestId) => _waiters.TryRemove(requestId, out _); private async Task EnsureStartedAsync(CancellationToken cancellationToken) { diff --git a/src/Vulthil.Messaging.RabbitMq/Requests/ResponseWaiter.cs b/src/Vulthil.Messaging.RabbitMq/Requests/ResponseWaiter.cs index 6e453b7..f15e3a7 100644 --- a/src/Vulthil.Messaging.RabbitMq/Requests/ResponseWaiter.cs +++ b/src/Vulthil.Messaging.RabbitMq/Requests/ResponseWaiter.cs @@ -1,39 +1,48 @@ using System.Text.Json; +using Vulthil.Messaging.RabbitMq.Envelope; using Vulthil.Results; namespace Vulthil.Messaging.RabbitMq.Requests; internal sealed class ResponseWaiter( TaskCompletionSource> tcs, - JsonSerializerOptions options) : IResponseWaiter where T : notnull + JsonSerializerOptions options, + Uri responseUrn) : IResponseWaiter where T : notnull { /// - /// Completes the pending request by deserializing the reply body into a - /// and resolving the awaiting task with the typed success value or a failure error. + /// Completes the pending request by deserializing the reply and resolving + /// the awaiting task. A reply whose message type is the response URN yields a success; the RPC fault URN + /// () yields a failure carrying the remote error; anything else is a protocol error. /// /// The raw reply payload received on the reply queue. public void Complete(ReadOnlySpan body) { try { - var envelope = JsonSerializer.Deserialize(body, options); + var envelope = JsonSerializer.Deserialize(body, options); + if (envelope is null) + { + tcs.TrySetResult(Result.Failure(Error.Failure("Messaging.Request.Deserialize", "Reply envelope was null."))); + return; + } - if (envelope is { IsSuccess: true }) + if (envelope.MessageType == responseUrn) { - var innerResult = JsonSerializer.Deserialize(envelope.Value, options); - if (innerResult is not null) - { - tcs.TrySetResult(Result.Success(innerResult)); - } - else - { - tcs.TrySetResult(Result.Failure(Error.Failure("Messaging.Request.Deserialize", "Inner message deserialization failed."))); - } + var value = envelope.Message.Deserialize(options); + tcs.TrySetResult(value is not null + ? Result.Success(value) + : Result.Failure(Error.Failure("Messaging.Request.Deserialize", "Inner message deserialization failed."))); + return; } - else + + if (envelope.MessageType == RpcFault.UrnUri) { - tcs.TrySetResult(Result.Failure(Error.Failure("Messaging.Request.Failure", envelope?.ErrorMessage ?? "Unknown remote error"))); + var fault = envelope.Message.Deserialize(options); + tcs.TrySetResult(Result.Failure(Error.Failure("Messaging.Request.Failure", fault?.Message ?? "Unknown remote error"))); + return; } + + tcs.TrySetResult(Result.Failure(Error.Failure("Messaging.Request.Deserialize", $"Unexpected reply message type '{envelope.MessageType}'."))); } catch (Exception ex) { diff --git a/src/Vulthil.Messaging.RabbitMq/Requests/RpcFault.cs b/src/Vulthil.Messaging.RabbitMq/Requests/RpcFault.cs new file mode 100644 index 0000000..4ef5488 --- /dev/null +++ b/src/Vulthil.Messaging.RabbitMq/Requests/RpcFault.cs @@ -0,0 +1,33 @@ +using System.Text.Json.Serialization; + +namespace Vulthil.Messaging.RabbitMq.Requests; + +/// +/// Wire payload for an RPC failure reply. Carried as the message of a +/// whose messageType is . Property +/// names are fixed (camelCase) so the reply round-trips regardless of the configured JSON naming policy. +/// +internal sealed record RpcFault +{ + /// The stable wire URN identifying an RPC fault reply payload. + public const string Urn = "urn:message:Vulthil:RpcFault"; + + /// The as a , for comparison against an envelope's message type. + public static readonly Uri UrnUri = new(Urn); + + /// The exception message describing the failure. + [JsonPropertyName("message")] + public required string Message { get; init; } + + /// The fully-qualified type name of the exception. + [JsonPropertyName("exceptionType")] + public required string ExceptionType { get; init; } + + /// The stack trace of the exception, or if unavailable. + [JsonPropertyName("stackTrace")] + public string? StackTrace { get; init; } + + /// The UTC timestamp when the fault occurred. + [JsonPropertyName("faultedAt")] + public required DateTimeOffset FaultedAt { get; init; } +} diff --git a/tests/Vulthil.Messaging.RabbitMq.Tests/ConsumeFilterPipelineTests.cs b/tests/Vulthil.Messaging.RabbitMq.Tests/ConsumeFilterPipelineTests.cs index 37a3e37..9b1b2fa 100644 --- a/tests/Vulthil.Messaging.RabbitMq.Tests/ConsumeFilterPipelineTests.cs +++ b/tests/Vulthil.Messaging.RabbitMq.Tests/ConsumeFilterPipelineTests.cs @@ -234,10 +234,10 @@ await handler.DispatchAsync( trace.ShouldBe(["log:before", "log:after"]); consumerInstance.Received.ShouldHaveSingleItem(); - var envelope = JsonSerializer.Deserialize(publishedBody.Span); + var envelope = JsonSerializer.Deserialize(publishedBody.Span); envelope.ShouldNotBeNull(); - envelope.IsSuccess.ShouldBeTrue(); - var response = JsonSerializer.Deserialize(envelope.Value); + envelope.MessageType.ShouldBe(new MessageConfiguration(typeof(TestResponse).FullName!).Urn); + var response = envelope.Message.Deserialize(); response!.Result.ShouldBe("Processed: query"); } @@ -293,9 +293,10 @@ await handler.DispatchAsync( // Assert consumerInstance.Received.ShouldBeEmpty(); - var envelope = JsonSerializer.Deserialize(publishedBody.Span); + var envelope = JsonSerializer.Deserialize(publishedBody.Span); envelope.ShouldNotBeNull(); - envelope.IsSuccess.ShouldBeFalse(); - envelope.ErrorMessage.ShouldContain("short-circuit"); + envelope.MessageType.ShouldBe(RpcFault.UrnUri); + var fault = envelope.Message.Deserialize(); + fault!.Message.ShouldContain("short-circuit"); } } diff --git a/tests/Vulthil.Messaging.RabbitMq.Tests/MessageTypeCacheTests.cs b/tests/Vulthil.Messaging.RabbitMq.Tests/MessageTypeCacheTests.cs index 4a6d431..dee9556 100644 --- a/tests/Vulthil.Messaging.RabbitMq.Tests/MessageTypeCacheTests.cs +++ b/tests/Vulthil.Messaging.RabbitMq.Tests/MessageTypeCacheTests.cs @@ -4,6 +4,7 @@ using Vulthil.Messaging.Abstractions.Consumers; using Vulthil.Messaging.Queues; using Vulthil.Messaging.RabbitMq.Consumers; +using Vulthil.Messaging.RabbitMq.Envelope; using Vulthil.Messaging.RabbitMq.Requests; using Vulthil.xUnit; @@ -210,12 +211,12 @@ public async Task CompiledRpcHandlerShouldCallConsumerAndPublishResponse() publishedProperties.ShouldNotBeNull(); publishedProperties.CorrelationId.ShouldBe("corr-1"); - var messageResult = JsonSerializer.Deserialize(publishedBody.Span); - messageResult.ShouldNotBeNull(); - messageResult.IsSuccess.ShouldBeTrue(); - messageResult.Value.ShouldNotBeNull(); + var envelope = JsonSerializer.Deserialize(publishedBody.Span); + envelope.ShouldNotBeNull(); + envelope.MessageType.ShouldBe(new MessageConfiguration(typeof(TestResponse).FullName!).Urn); + envelope.RequestId.ShouldBe("corr-1"); - var response = JsonSerializer.Deserialize(messageResult.Value); + var response = envelope.Message.Deserialize(); response.ShouldNotBeNull(); response.Result.ShouldBe("Processed: Find users"); } @@ -266,10 +267,13 @@ await handler.DispatchAsync( CancellationToken.None); // Assert - var messageResult = JsonSerializer.Deserialize(publishedBody.Span); - messageResult.ShouldNotBeNull(); - messageResult.IsSuccess.ShouldBeFalse(); - messageResult.ErrorMessage.ShouldContain("failed to process request"); + var envelope = JsonSerializer.Deserialize(publishedBody.Span); + envelope.ShouldNotBeNull(); + envelope.MessageType.ShouldBe(RpcFault.UrnUri); + + var fault = envelope.Message.Deserialize(); + fault.ShouldNotBeNull(); + fault.Message.ShouldContain("failed to process request"); } [Fact] diff --git a/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqPublisherExtendedTests.cs b/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqPublisherExtendedTests.cs index d3cd7c5..a0faa3d 100644 --- a/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqPublisherExtendedTests.cs +++ b/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqPublisherExtendedTests.cs @@ -107,6 +107,21 @@ public async Task PublishAsyncShouldPublishToCorrectExchange() capturedExchange.ShouldBe(typeof(TestMessage).FullName); } + [Fact] + public async Task InternalSendAsyncSurfacesFailureWhenTheBrokerRejectsTheMessage() + { + // Arrange — with publisher confirms enabled, an unroutable/nacked publish throws instead of + // silently succeeding; a send must surface that failure rather than report success. + _channelMock.Setup(x => x.BasicPublishAsync( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException("broker rejected the message")); + + // Act & Assert + var ex = await Should.ThrowAsync( + () => Target.InternalSendAsync([1, 2, 3], new BasicProperties(), "missing-queue", CancellationToken)); + ex.Message.ShouldContain("broker rejected"); + } + private sealed class TestMessage { public string Content { get; set; } = string.Empty; diff --git a/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqPublisherTests.cs b/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqPublisherTests.cs index d482f8d..79d07ac 100644 --- a/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqPublisherTests.cs +++ b/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqPublisherTests.cs @@ -44,11 +44,11 @@ public async Task PublishAsyncWithValidMessagePublishesSuccessfully() // Act await Target.PublishAsync(message, cancellationToken: CancellationToken); - // Assert + // Assert — publish is pub/sub, so it is not mandatory (zero subscribers is normal). _channelMock.Verify(x => x.BasicPublishAsync( typeof(TestMessage).FullName!, string.Empty, - true, + false, It.IsAny(), It.IsAny>(), CancellationToken), Times.Once); diff --git a/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqRequesterTests.cs b/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqRequesterTests.cs index afeba6f..a017919 100644 --- a/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqRequesterTests.cs +++ b/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqRequesterTests.cs @@ -1,5 +1,7 @@ using System.Diagnostics; +using System.Text.Json; using RabbitMQ.Client; +using Vulthil.Messaging.RabbitMq.Envelope; using Vulthil.Messaging.RabbitMq.Publishing; using Vulthil.Messaging.RabbitMq.Requests; using Vulthil.xUnit; @@ -66,6 +68,47 @@ public async Task RequestAsyncReturnsTimeoutFailureWhenNoResponseArrivesWithinPe stopwatch.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(5)); } + [Fact] + public async Task RequestAsyncCorrelatesOnAFreshRequestIdDistinctFromBusinessCorrelationId() + { + // Arrange + const string businessCorrelationId = "order-42"; + BasicProperties? capturedProps = null; + byte[]? capturedBody = null; + GetMock() + .Setup(p => p.InternalPublishAsync( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((byte[] body, BasicProperties props, string _, MessageConfiguration _, CancellationToken _) => + { + capturedBody = body; + capturedProps = props; + }) + .Returns(Task.CompletedTask); + + // Act + var result = await Target.RequestAsync( + new TimeoutRequest("ping"), + context => + { + context.SetCorrelationId(businessCorrelationId); + context.SetTimeout(TimeSpan.FromMilliseconds(200)); + return ValueTask.CompletedTask; + }, + CancellationToken); + + // Assert + result.IsFailure.ShouldBeTrue(); + capturedProps.ShouldNotBeNull(); + capturedProps.CorrelationId.ShouldNotBe(businessCorrelationId); + Guid.TryParse(capturedProps.CorrelationId, out _).ShouldBeTrue(); + + capturedBody.ShouldNotBeNull(); + var envelope = JsonSerializer.Deserialize(capturedBody); + envelope.ShouldNotBeNull(); + envelope.CorrelationId.ShouldBe(businessCorrelationId); + envelope.RequestId.ShouldBe(capturedProps.CorrelationId); + } + private sealed record TimeoutRequest(string Value); private sealed record TimeoutResponse(string Value); From f9e23ce2389741ff80086fa6c979ba2f519e88bf Mon Sep 17 00:00:00 2001 From: Vulthil Date: Mon, 1 Jun 2026 22:45:38 +0200 Subject: [PATCH 18/42] Fixed warnings in build --- samples/Directory.Build.props | 1 + samples/WebApi/WebApi.Tests/ExternalWeatherClientTests.cs | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/samples/Directory.Build.props b/samples/Directory.Build.props index 4103f79..424cc30 100644 --- a/samples/Directory.Build.props +++ b/samples/Directory.Build.props @@ -5,6 +5,7 @@ enable false false + $(NoWarn);S1075 diff --git a/samples/WebApi/WebApi.Tests/ExternalWeatherClientTests.cs b/samples/WebApi/WebApi.Tests/ExternalWeatherClientTests.cs index a1b8ced..7ceb3f9 100644 --- a/samples/WebApi/WebApi.Tests/ExternalWeatherClientTests.cs +++ b/samples/WebApi/WebApi.Tests/ExternalWeatherClientTests.cs @@ -9,7 +9,7 @@ namespace WebApi.Tests; public sealed class ExternalWeatherClientTests(CustomWebApplicationFactory factory, ITestOutputHelper testOutputHelper) : BaseIntegrationTestCase(factory, testOutputHelper) { - private IExternalWeatherClient Client => ScopedServices.GetRequiredService(); + private IExternalWeatherClient WeatherClient => ScopedServices.GetRequiredService(); [Fact] public async Task GetForecast_returns_strongly_typed_mocked_response() @@ -21,7 +21,7 @@ public async Task GetForecast_returns_strongly_typed_mocked_response() .WithHeader("X-Source", "mock"); // Act - var forecast = await Client.GetForecastAsync("london", CancellationToken); + var forecast = await WeatherClient.GetForecastAsync("london", CancellationToken); // Assert forecast.ShouldNotBeNull(); @@ -39,7 +39,7 @@ public async Task GetForecast_returns_captured_json_string() .RespondWithJson(HttpStatusCode.OK, capturedJson); // Act - var forecast = await Client.GetForecastAsync("paris", CancellationToken); + var forecast = await WeatherClient.GetForecastAsync("paris", CancellationToken); // Assert forecast.ShouldNotBeNull(); From 49db9519367830c159f9552c39ab94cf073f0361 Mon Sep 17 00:00:00 2001 From: Vulthil Date: Tue, 2 Jun 2026 19:26:46 +0200 Subject: [PATCH 19/42] fix(messaging): serialize consumer channel writes on partitioned queues --- .../Consumers/RabbitMqConsumerWorker.cs | 65 +++++++++++++++---- 1 file changed, 53 insertions(+), 12 deletions(-) diff --git a/src/Vulthil.Messaging.RabbitMq/Consumers/RabbitMqConsumerWorker.cs b/src/Vulthil.Messaging.RabbitMq/Consumers/RabbitMqConsumerWorker.cs index fc73bc0..e0d2005 100644 --- a/src/Vulthil.Messaging.RabbitMq/Consumers/RabbitMqConsumerWorker.cs +++ b/src/Vulthil.Messaging.RabbitMq/Consumers/RabbitMqConsumerWorker.cs @@ -26,6 +26,12 @@ internal sealed class RabbitMqConsumerWorker : IAsyncDisposable private readonly bool _partitioned; private readonly ConcurrentDictionary _inFlight = new(); + // RabbitMQ channels must not be used concurrently. On a partitioned queue the lanes complete in parallel + // and each settles its delivery (ack/nack/retry-republish/fault) on this shared channel, so every channel + // write is serialized through this gate to avoid interleaved frames. Message processing stays parallel; + // only the brief settle/publish frames are serialized. + private readonly SemaphoreSlim _channelGate = new(1, 1); + private JsonSerializerOptions _jsonOptions => _messageConfigurationProvider.JsonSerializerOptions; private string? _consumerTag; @@ -50,6 +56,27 @@ public RabbitMqConsumerWorker( _partitioned = partitioned; } + /// + /// Serializes a single channel write. RabbitMQ channels must not be used concurrently, and a partitioned + /// queue's lanes settle in parallel on the shared channel, so every ack/nack/publish goes through here. + /// + private async Task OnChannelAsync(Func channelOperation) + { + await _channelGate.WaitAsync(); + try + { + await channelOperation(); + } + finally + { + _channelGate.Release(); + } + } + + private Task AckAsync(BasicDeliverEventArgs ea) => OnChannelAsync(() => _channel.BasicAckAsync(ea.DeliveryTag, false)); + + private Task NackAsync(BasicDeliverEventArgs ea) => OnChannelAsync(() => _channel.BasicNackAsync(ea.DeliveryTag, false, requeue: false)); + public async Task StartAsync(CancellationToken cancellationToken) { var consumer = new AsyncEventingBasicConsumer(_channel); @@ -131,7 +158,7 @@ private async Task ProcessAsync(PreparedDelivery prepared, BasicDeliverEventArgs try { await DispatchHandlersAsync(prepared.Plan, prepared.Message, ea, prepared.Envelope); - await _channel.BasicAckAsync(ea.DeliveryTag, false); + await AckAsync(ea); activity?.SetStatus(ActivityStatusCode.Ok); } catch (Exception ex) @@ -154,7 +181,7 @@ private async Task ExecuteWithInMemoryRetryAsync(RetryPolicyDefinition policy, P try { await DispatchHandlersAsync(prepared.Plan, prepared.Message, ea, prepared.Envelope); - await _channel.BasicAckAsync(ea.DeliveryTag, false); + await AckAsync(ea); activity?.SetStatus(ActivityStatusCode.Ok); return; } @@ -173,7 +200,7 @@ private async Task ExecuteWithInMemoryRetryAsync(RetryPolicyDefinition policy, P activity?.AddException(ex); MessagingLog.ConsumerFailed(_logger, ex, _queueDefinition.Name, prepared.DiagnosticTypeName, ea.RoutingKey); await PublishFaultIfRequestedAsync(ex, ea, ea.BasicProperties.Headers ?? new Dictionary()); - await _channel.BasicNackAsync(ea.DeliveryTag, false, requeue: false); + await NackAsync(ea); return; } @@ -229,7 +256,7 @@ private async Task HandleFailureAsync(Exception ex, BasicDeliverEventArgs ea, st { MessagingLog.ConsumerFailed(_logger, ex, _queueDefinition.Name, messageTypeName, ea.RoutingKey); await PublishFaultIfRequestedAsync(ex, ea, headers); - await _channel.BasicNackAsync(ea.DeliveryTag, false, requeue: false); + await NackAsync(ea); return; } @@ -246,14 +273,14 @@ private async Task HandleFailureAsync(Exception ex, BasicDeliverEventArgs ea, st props.Expiration = delay.TotalMilliseconds.ToString(CultureInfo.InvariantCulture); - await _channel.BasicPublishAsync($"{_queueDefinition.Name}.Retry", ea.RoutingKey, true, props, ea.Body); - await _channel.BasicAckAsync(ea.DeliveryTag, false); + await OnChannelAsync(() => _channel.BasicPublishAsync($"{_queueDefinition.Name}.Retry", ea.RoutingKey, true, props, ea.Body)); + await AckAsync(ea); return; } MessagingLog.ConsumerFailed(_logger, ex, _queueDefinition.Name, messageTypeName, ea.RoutingKey); await PublishFaultIfRequestedAsync(ex, ea, headers); - await _channel.BasicNackAsync(ea.DeliveryTag, false, requeue: false); + await NackAsync(ea); } private async Task PublishFaultIfRequestedAsync(Exception ex, BasicDeliverEventArgs ea, IDictionary headers) @@ -286,7 +313,7 @@ private async Task PublishFaultIfRequestedAsync(Exception ex, BasicDeliverEventA Timestamp = new AmqpTimestamp(DateTimeOffset.UtcNow.ToUnixTimeSeconds()) }; - await _channel.BasicPublishAsync(_messageConfigurationProvider.FaultExchangeName, faultAddressKey, false, faultProps, faultBody); + await OnChannelAsync(() => _channel.BasicPublishAsync(_messageConfigurationProvider.FaultExchangeName, faultAddressKey, false, faultProps, faultBody)); } catch (Exception faultEx) { @@ -327,7 +354,7 @@ private async Task PublishFaultIfRequestedAsync(Exception ex, BasicDeliverEventA if (plan is null) { MessagingLog.NoExecutionPlan(_logger, _queueDefinition.Name, diagnosticTypeName, ea.RoutingKey); - await _channel.BasicAckAsync(ea.DeliveryTag, false); + await AckAsync(ea); return null; } @@ -341,14 +368,14 @@ private async Task PublishFaultIfRequestedAsync(Exception ex, BasicDeliverEventA catch (JsonException jsonEx) { MessagingLog.PoisonMessage(_logger, jsonEx, _queueDefinition.Name, diagnosticTypeName, ea.RoutingKey); - await _channel.BasicNackAsync(ea.DeliveryTag, false, false); + await NackAsync(ea); return null; } if (message is null) { MessagingLog.PoisonMessage(_logger, new JsonException("Deserializer returned null."), _queueDefinition.Name, diagnosticTypeName, ea.RoutingKey); - await _channel.BasicNackAsync(ea.DeliveryTag, false, false); + await NackAsync(ea); return null; } @@ -393,7 +420,17 @@ public async ValueTask DisposeAsync() { if (!string.IsNullOrEmpty(_consumerTag)) { - await _channel.BasicCancelAsync(_consumerTag); + // Cancelling stops new deliveries while in-flight lanes may still be settling, so it runs + // through the same gate as the settle operations. + await _channelGate.WaitAsync(); + try + { + await _channel.BasicCancelAsync(_consumerTag); + } + finally + { + _channelGate.Release(); + } } // Drain in-flight partitioned work so deferred acks complete before the channel closes; anything @@ -414,6 +451,10 @@ public async ValueTask DisposeAsync() { // Draining took too long; unacked deliveries are requeued when the channel closes. } + finally + { + _channelGate.Dispose(); + } } private sealed record PreparedDelivery(MessageExecutionPlan Plan, object Message, MessageEnvelope? Envelope, string DiagnosticTypeName); From ba5467d6d26ef30447ac21222494e81c819ce745 Mon Sep 17 00:00:00 2001 From: Vulthil Date: Wed, 3 Jun 2026 21:50:51 +0200 Subject: [PATCH 20/42] fix(messaging): advance in-memory RetryCount; make consume Headers read-only --- .../Consumers/IMessageContext.cs | 2 +- .../PublicAPI.Unshipped.txt | 2 +- .../Consumers/MessageContext.cs | 6 ++-- .../Consumers/RabbitMqConsumerWorker.cs | 18 +++++++++- .../RabbitMqConsumerWorkerTests.cs | 34 +++++++++++++++++++ .../Filters/LoggingConsumeFilterTests.cs | 2 +- 6 files changed, 57 insertions(+), 7 deletions(-) create mode 100644 tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqConsumerWorkerTests.cs diff --git a/src/Vulthil.Messaging.Abstractions/Consumers/IMessageContext.cs b/src/Vulthil.Messaging.Abstractions/Consumers/IMessageContext.cs index 3da742d..c6466c6 100644 --- a/src/Vulthil.Messaging.Abstractions/Consumers/IMessageContext.cs +++ b/src/Vulthil.Messaging.Abstractions/Consumers/IMessageContext.cs @@ -55,7 +55,7 @@ public interface IMessageContext /// /// Gets the transport headers associated with the message, containing custom metadata. /// - IDictionary Headers { get; } + IReadOnlyDictionary Headers { get; } // --- Timing & Lifecycle --- /// diff --git a/src/Vulthil.Messaging.Abstractions/PublicAPI.Unshipped.txt b/src/Vulthil.Messaging.Abstractions/PublicAPI.Unshipped.txt index 3eecc88..2dd6e1c 100644 --- a/src/Vulthil.Messaging.Abstractions/PublicAPI.Unshipped.txt +++ b/src/Vulthil.Messaging.Abstractions/PublicAPI.Unshipped.txt @@ -25,7 +25,7 @@ Vulthil.Messaging.Abstractions.Consumers.IMessageContext.CorrelationId.get -> st Vulthil.Messaging.Abstractions.Consumers.IMessageContext.DestinationAddress.get -> System.Uri? Vulthil.Messaging.Abstractions.Consumers.IMessageContext.ExpirationTime.get -> System.DateTimeOffset? Vulthil.Messaging.Abstractions.Consumers.IMessageContext.FaultAddress.get -> System.Uri? -Vulthil.Messaging.Abstractions.Consumers.IMessageContext.Headers.get -> System.Collections.Generic.IDictionary! +Vulthil.Messaging.Abstractions.Consumers.IMessageContext.Headers.get -> System.Collections.Generic.IReadOnlyDictionary! Vulthil.Messaging.Abstractions.Consumers.IMessageContext.InitiatorId.get -> string? Vulthil.Messaging.Abstractions.Consumers.IMessageContext.MessageId.get -> string? Vulthil.Messaging.Abstractions.Consumers.IMessageContext.PublishAsync(TMessage message, System.Func? configure = null) -> System.Threading.Tasks.Task! diff --git a/src/Vulthil.Messaging.RabbitMq/Consumers/MessageContext.cs b/src/Vulthil.Messaging.RabbitMq/Consumers/MessageContext.cs index b79683c..71df61d 100644 --- a/src/Vulthil.Messaging.RabbitMq/Consumers/MessageContext.cs +++ b/src/Vulthil.Messaging.RabbitMq/Consumers/MessageContext.cs @@ -26,7 +26,7 @@ internal record MessageContext : IMessageContext /// public required string RoutingKey { get; init; } /// - public required IDictionary Headers { get; init; } + public required IReadOnlyDictionary Headers { get; init; } /// public int RetryCount { get; init; } /// @@ -153,7 +153,7 @@ private static MessageContext BuildMetadata( CorrelationId = props.CorrelationId ?? string.Empty, RequestId = props.CorrelationId, RoutingKey = ea.RoutingKey, - Headers = headers, + Headers = headers.ToDictionary(), Redelivered = ea.Redelivered, RetryCount = RabbitMqConstants.GetRetryCount(headers), ConversationId = RabbitMqConstants.GetHeaderString(headers, "ConversationId"), @@ -187,7 +187,7 @@ private static MessageContext BuildTypedMetadata( CorrelationId = props.CorrelationId ?? string.Empty, RequestId = props.CorrelationId, RoutingKey = ea.RoutingKey, - Headers = headers, + Headers = headers.ToDictionary(), Redelivered = ea.Redelivered, RetryCount = RabbitMqConstants.GetRetryCount(headers), ConversationId = RabbitMqConstants.GetHeaderString(headers, "ConversationId"), diff --git a/src/Vulthil.Messaging.RabbitMq/Consumers/RabbitMqConsumerWorker.cs b/src/Vulthil.Messaging.RabbitMq/Consumers/RabbitMqConsumerWorker.cs index e0d2005..6355fa0 100644 --- a/src/Vulthil.Messaging.RabbitMq/Consumers/RabbitMqConsumerWorker.cs +++ b/src/Vulthil.Messaging.RabbitMq/Consumers/RabbitMqConsumerWorker.cs @@ -176,11 +176,13 @@ private async Task ProcessAsync(PreparedDelivery prepared, BasicDeliverEventArgs /// private async Task ExecuteWithInMemoryRetryAsync(RetryPolicyDefinition policy, PreparedDelivery prepared, BasicDeliverEventArgs ea, Activity? activity) { + var baseRetryCount = RabbitMqConstants.GetRetryCount(ea.BasicProperties.Headers); for (var attempt = 0; attempt <= policy.MaxRetryCount; attempt++) { + var attemptDelivery = attempt == 0 ? ea : WithRetryCount(ea, baseRetryCount + attempt); try { - await DispatchHandlersAsync(prepared.Plan, prepared.Message, ea, prepared.Envelope); + await DispatchHandlersAsync(prepared.Plan, prepared.Message, attemptDelivery, prepared.Envelope); await AckAsync(ea); activity?.SetStatus(ActivityStatusCode.Ok); return; @@ -335,6 +337,20 @@ private async Task PublishFaultIfRequestedAsync(Exception ex, BasicDeliverEventA return queue.DefaultRetryPolicy; } + /// + /// Returns a copy of whose x-retry-count header is set to + /// , so a consumer reading sees the + /// current in-memory attempt. The delivery's AMQP properties are read-only on the receive side, hence the copy. + /// + internal static BasicDeliverEventArgs WithRetryCount(BasicDeliverEventArgs ea, int retryCount) + { + var properties = new BasicProperties(ea.BasicProperties); + properties.Headers ??= new Dictionary(); + properties.Headers["x-retry-count"] = retryCount; + return new BasicDeliverEventArgs( + ea.ConsumerTag, ea.DeliveryTag, ea.Redelivered, ea.Exchange, ea.RoutingKey, properties, ea.Body, ea.CancellationToken); + } + /// /// Parses the envelope, resolves the execution plan, and deserializes the message. Settles the delivery /// itself for terminal cases — acks (drops) when no plan matches, nacks on a poison/undeserializable body — diff --git a/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqConsumerWorkerTests.cs b/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqConsumerWorkerTests.cs new file mode 100644 index 0000000..f3e4962 --- /dev/null +++ b/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqConsumerWorkerTests.cs @@ -0,0 +1,34 @@ +using RabbitMQ.Client; +using RabbitMQ.Client.Events; +using Vulthil.Messaging.RabbitMq.Consumers; +using Vulthil.xUnit; + +namespace Vulthil.Messaging.RabbitMq.Tests; + +public sealed class RabbitMqConsumerWorkerTests : BaseUnitTestCase +{ + [Fact] + public void WithRetryCountSurfacesTheAttemptThroughMessageContextRetryCount() + { + // Arrange — a delivery as first received, carrying no retry header. + var delivery = new BasicDeliverEventArgs( + consumerTag: "consumer-tag", + deliveryTag: 7, + redelivered: false, + exchange: "exchange", + routingKey: "rk", + new BasicProperties { Headers = new Dictionary() }, + ReadOnlyMemory.Empty); + + // Act — the worker rewrites the delivery for the third in-memory attempt. + var retried = RabbitMqConsumerWorker.WithRetryCount(delivery, 3); + var context = MessageContext.CreateContext(new TestMessage("payload"), retried); + + // Assert + context.RetryCount.ShouldBe(3); + retried.DeliveryTag.ShouldBe(7ul); + retried.RoutingKey.ShouldBe("rk"); + } + + private sealed record TestMessage(string Value); +} diff --git a/tests/Vulthil.Messaging.Tests/Filters/LoggingConsumeFilterTests.cs b/tests/Vulthil.Messaging.Tests/Filters/LoggingConsumeFilterTests.cs index d6f6d86..4dd76c2 100644 --- a/tests/Vulthil.Messaging.Tests/Filters/LoggingConsumeFilterTests.cs +++ b/tests/Vulthil.Messaging.Tests/Filters/LoggingConsumeFilterTests.cs @@ -23,7 +23,7 @@ private sealed class StubMessageContext : IMessageContext public Uri? ResponseAddress { get; init; } public Uri? FaultAddress { get; init; } public string RoutingKey { get; init; } = string.Empty; - public IDictionary Headers { get; init; } = new Dictionary(); + public IReadOnlyDictionary Headers { get; init; } = new Dictionary(); public DateTimeOffset? SentTime { get; init; } public DateTimeOffset? ExpirationTime { get; init; } public int RetryCount { get; init; } From 5b6cdd0a7cedf248b7e06813191c7c6a5ef01233 Mon Sep 17 00:00:00 2001 From: Vulthil Date: Wed, 3 Jun 2026 22:10:33 +0200 Subject: [PATCH 21/42] refactor(messaging): internalize configurator impls; align UseRetry nullability --- src/Vulthil.Messaging/PublicAPI.Unshipped.txt | 10 +--------- src/Vulthil.Messaging/Queues/BaseConfigurator.cs | 2 +- src/Vulthil.Messaging/Queues/ConsumerConfigurator.cs | 2 +- src/Vulthil.Messaging/Queues/IBaseConfigurator.cs | 2 +- .../Queues/RequestConsumerConfigurator.cs | 2 +- 5 files changed, 5 insertions(+), 13 deletions(-) diff --git a/src/Vulthil.Messaging/PublicAPI.Unshipped.txt b/src/Vulthil.Messaging/PublicAPI.Unshipped.txt index bae7dca..07899c3 100644 --- a/src/Vulthil.Messaging/PublicAPI.Unshipped.txt +++ b/src/Vulthil.Messaging/PublicAPI.Unshipped.txt @@ -67,12 +67,6 @@ Vulthil.Messaging.PartitionSpec.KeySelector.init -> void Vulthil.Messaging.PartitionSpec.Partitioner.get -> Vulthil.Messaging.Partitioner! Vulthil.Messaging.PartitionSpec.Partitioner.init -> void Vulthil.Messaging.PartitionSpec.PartitionSpec(Vulthil.Messaging.Partitioner! Partitioner, System.Delegate! KeySelector) -> void -Vulthil.Messaging.Queues.BaseConfigurator -Vulthil.Messaging.Queues.BaseConfigurator.BaseConfigurator() -> void -Vulthil.Messaging.Queues.BaseConfigurator.Self.get -> TConfigurator! -Vulthil.Messaging.Queues.BaseConfigurator.UseRetry(System.Action! value) -> TConfigurator! -Vulthil.Messaging.Queues.ConsumerConfigurator -Vulthil.Messaging.Queues.ConsumerConfigurator.ConsumerConfigurator() -> void Vulthil.Messaging.Queues.ConsumerRegistration Vulthil.Messaging.Queues.ConsumerType Vulthil.Messaging.Queues.ConsumerType.ConsumerType(System.Type! Type) -> void @@ -87,7 +81,7 @@ Vulthil.Messaging.Queues.DeadLetterDefinition.ExchangeName.set -> void Vulthil.Messaging.Queues.DeadLetterDefinition.QueueName.get -> string? Vulthil.Messaging.Queues.DeadLetterDefinition.QueueName.set -> void Vulthil.Messaging.Queues.IBaseConfigurator -Vulthil.Messaging.Queues.IBaseConfigurator.UseRetry(System.Action! value) -> TConfigurator +Vulthil.Messaging.Queues.IBaseConfigurator.UseRetry(System.Action! value) -> TConfigurator! Vulthil.Messaging.Queues.IConsumerConfigurator Vulthil.Messaging.Queues.IQueueConfigurator Vulthil.Messaging.Queues.IQueueConfigurator.AddConsumer(System.Action!>? configure = null) -> Vulthil.Messaging.Queues.IQueueConfigurator! @@ -151,8 +145,6 @@ Vulthil.Messaging.Queues.Registration.MessageType.get -> Vulthil.Messaging.Queue Vulthil.Messaging.Queues.Registration.MessageType.init -> void Vulthil.Messaging.Queues.Registration.RetryPolicy.get -> Vulthil.Messaging.Queues.RetryPolicyDefinition? Vulthil.Messaging.Queues.Registration.RetryPolicy.init -> void -Vulthil.Messaging.Queues.RequestConsumerConfigurator -Vulthil.Messaging.Queues.RequestConsumerConfigurator.RequestConsumerConfigurator() -> void Vulthil.Messaging.Queues.RequestConsumerRegistration Vulthil.Messaging.Queues.RequestConsumerRegistration.ResponseType.get -> System.Type! Vulthil.Messaging.Queues.RequestConsumerRegistration.ResponseType.init -> void diff --git a/src/Vulthil.Messaging/Queues/BaseConfigurator.cs b/src/Vulthil.Messaging/Queues/BaseConfigurator.cs index 21ed2ac..583c8c4 100644 --- a/src/Vulthil.Messaging/Queues/BaseConfigurator.cs +++ b/src/Vulthil.Messaging/Queues/BaseConfigurator.cs @@ -6,7 +6,7 @@ namespace Vulthil.Messaging.Queues; /// concrete configurator classes can inherit and remain empty. /// /// The derived configurator interface (e.g. ). -public abstract class BaseConfigurator : IBaseConfigurator +internal abstract class BaseConfigurator : IBaseConfigurator where TConfigurator : class, IBaseConfigurator { internal RetryPolicyDefinition? RetryPolicy { get; private set; } diff --git a/src/Vulthil.Messaging/Queues/ConsumerConfigurator.cs b/src/Vulthil.Messaging/Queues/ConsumerConfigurator.cs index 5dac96c..68d5039 100644 --- a/src/Vulthil.Messaging/Queues/ConsumerConfigurator.cs +++ b/src/Vulthil.Messaging/Queues/ConsumerConfigurator.cs @@ -7,6 +7,6 @@ namespace Vulthil.Messaging.Queues; /// returning — no body needed. /// /// The consumer type. -public sealed class ConsumerConfigurator +internal sealed class ConsumerConfigurator : BaseConfigurator>, IConsumerConfigurator where TConsumer : IConsumer; diff --git a/src/Vulthil.Messaging/Queues/IBaseConfigurator.cs b/src/Vulthil.Messaging/Queues/IBaseConfigurator.cs index e5d8e7f..d4c9af7 100644 --- a/src/Vulthil.Messaging/Queues/IBaseConfigurator.cs +++ b/src/Vulthil.Messaging/Queues/IBaseConfigurator.cs @@ -7,7 +7,7 @@ namespace Vulthil.Messaging.Queues; /// without needing their own explicit interface implementations. /// public interface IBaseConfigurator - where TConfigurator : IBaseConfigurator + where TConfigurator : class, IBaseConfigurator { /// Configures a retry policy and returns the typed configurator for chaining. TConfigurator UseRetry(Action value); diff --git a/src/Vulthil.Messaging/Queues/RequestConsumerConfigurator.cs b/src/Vulthil.Messaging/Queues/RequestConsumerConfigurator.cs index 2682719..2f3fac0 100644 --- a/src/Vulthil.Messaging/Queues/RequestConsumerConfigurator.cs +++ b/src/Vulthil.Messaging/Queues/RequestConsumerConfigurator.cs @@ -6,6 +6,6 @@ namespace Vulthil.Messaging.Queues; /// Concrete request-consumer configurator. Inherits /// returning — no body needed. /// -public sealed class RequestConsumerConfigurator +internal sealed class RequestConsumerConfigurator : BaseConfigurator>, IRequestConfigurator where TConsumer : IRequestConsumer; From 6aa09971e491612f18aea21b6239665ae2fb68dc Mon Sep 17 00:00:00 2001 From: Vulthil Date: Wed, 3 Jun 2026 22:39:30 +0200 Subject: [PATCH 22/42] refactor(messaging): typed Fault.OriginalContext snapshot; drop Null* contexts --- .../Consumers/Fault.cs | 4 +- .../Consumers/MessageContextSnapshot.cs | 32 ++++++++ .../PublicAPI.Unshipped.txt | 25 +++++- .../Consumers/MessageContext.cs | 77 +++++++++++-------- .../Consumers/NullPublisher.cs | 26 ------- .../Consumers/PartitionKeyExtractorFactory.cs | 3 +- .../Consumers/RabbitMqConsumerWorker.cs | 2 +- .../Sending/NullSendEndpointProvider.cs | 19 ----- .../MessageContextSendTests.cs | 21 ++++- .../MessageContextTests.cs | 32 +++++++- .../RabbitMqSendEndpointProviderTests.cs | 14 ---- 11 files changed, 152 insertions(+), 103 deletions(-) create mode 100644 src/Vulthil.Messaging.Abstractions/Consumers/MessageContextSnapshot.cs delete mode 100644 src/Vulthil.Messaging.RabbitMq/Consumers/NullPublisher.cs delete mode 100644 src/Vulthil.Messaging.RabbitMq/Sending/NullSendEndpointProvider.cs diff --git a/src/Vulthil.Messaging.Abstractions/Consumers/Fault.cs b/src/Vulthil.Messaging.Abstractions/Consumers/Fault.cs index 6969bd6..6585ed7 100644 --- a/src/Vulthil.Messaging.Abstractions/Consumers/Fault.cs +++ b/src/Vulthil.Messaging.Abstractions/Consumers/Fault.cs @@ -27,7 +27,7 @@ public record Fault where TMessage : notnull /// public required DateTimeOffset FaultedAt { get; init; } /// - /// Gets the original message context at the time of the fault. + /// Gets a serializable snapshot of the original message context at the time of the fault. /// - public required IMessageContext OriginalContext { get; init; } + public required MessageContextSnapshot OriginalContext { get; init; } } diff --git a/src/Vulthil.Messaging.Abstractions/Consumers/MessageContextSnapshot.cs b/src/Vulthil.Messaging.Abstractions/Consumers/MessageContextSnapshot.cs new file mode 100644 index 0000000..3d0246b --- /dev/null +++ b/src/Vulthil.Messaging.Abstractions/Consumers/MessageContextSnapshot.cs @@ -0,0 +1,32 @@ +namespace Vulthil.Messaging.Abstractions.Consumers; + +/// +/// An immutable, serializable snapshot of an 's transport metadata captured at a +/// point in time (e.g. when a is produced). Unlike +/// it carries no behavior and no live transport binding, so it round-trips cleanly through serialization. +/// +public sealed record MessageContextSnapshot +{ + /// The unique message identifier, or if not set. + public string? MessageId { get; init; } + /// The request identifier correlating a reply to its request, or if not set. + public string? RequestId { get; init; } + /// The business correlation identifier, or if not set. + public string? CorrelationId { get; init; } + /// The conversation identifier grouping related messages, or if not set. + public string? ConversationId { get; init; } + /// The identifier of the message that initiated this chain, or if not set. + public string? InitiatorId { get; init; } + /// The address of the endpoint that produced the message, or if unknown. + public Uri? SourceAddress { get; init; } + /// The intended destination address, or if not set. + public Uri? DestinationAddress { get; init; } + /// The address where replies should be sent, or if none. + public Uri? ResponseAddress { get; init; } + /// The address where fault notifications should be sent, or for the default. + public Uri? FaultAddress { get; init; } + /// The routing key the transport used to deliver the message. + public string RoutingKey { get; init; } = string.Empty; + /// The number of times the message had been retried when the snapshot was taken. + public int RetryCount { get; init; } +} diff --git a/src/Vulthil.Messaging.Abstractions/PublicAPI.Unshipped.txt b/src/Vulthil.Messaging.Abstractions/PublicAPI.Unshipped.txt index 2dd6e1c..d9f2d6e 100644 --- a/src/Vulthil.Messaging.Abstractions/PublicAPI.Unshipped.txt +++ b/src/Vulthil.Messaging.Abstractions/PublicAPI.Unshipped.txt @@ -8,10 +8,33 @@ Vulthil.Messaging.Abstractions.Consumers.Fault.FaultedAt.get -> System Vulthil.Messaging.Abstractions.Consumers.Fault.FaultedAt.init -> void Vulthil.Messaging.Abstractions.Consumers.Fault.Message.get -> TMessage Vulthil.Messaging.Abstractions.Consumers.Fault.Message.init -> void -Vulthil.Messaging.Abstractions.Consumers.Fault.OriginalContext.get -> Vulthil.Messaging.Abstractions.Consumers.IMessageContext! +Vulthil.Messaging.Abstractions.Consumers.Fault.OriginalContext.get -> Vulthil.Messaging.Abstractions.Consumers.MessageContextSnapshot! Vulthil.Messaging.Abstractions.Consumers.Fault.OriginalContext.init -> void Vulthil.Messaging.Abstractions.Consumers.Fault.StackTrace.get -> string? Vulthil.Messaging.Abstractions.Consumers.Fault.StackTrace.init -> void +Vulthil.Messaging.Abstractions.Consumers.MessageContextSnapshot +Vulthil.Messaging.Abstractions.Consumers.MessageContextSnapshot.ConversationId.get -> string? +Vulthil.Messaging.Abstractions.Consumers.MessageContextSnapshot.ConversationId.init -> void +Vulthil.Messaging.Abstractions.Consumers.MessageContextSnapshot.CorrelationId.get -> string? +Vulthil.Messaging.Abstractions.Consumers.MessageContextSnapshot.CorrelationId.init -> void +Vulthil.Messaging.Abstractions.Consumers.MessageContextSnapshot.DestinationAddress.get -> System.Uri? +Vulthil.Messaging.Abstractions.Consumers.MessageContextSnapshot.DestinationAddress.init -> void +Vulthil.Messaging.Abstractions.Consumers.MessageContextSnapshot.FaultAddress.get -> System.Uri? +Vulthil.Messaging.Abstractions.Consumers.MessageContextSnapshot.FaultAddress.init -> void +Vulthil.Messaging.Abstractions.Consumers.MessageContextSnapshot.InitiatorId.get -> string? +Vulthil.Messaging.Abstractions.Consumers.MessageContextSnapshot.InitiatorId.init -> void +Vulthil.Messaging.Abstractions.Consumers.MessageContextSnapshot.MessageId.get -> string? +Vulthil.Messaging.Abstractions.Consumers.MessageContextSnapshot.MessageId.init -> void +Vulthil.Messaging.Abstractions.Consumers.MessageContextSnapshot.RequestId.get -> string? +Vulthil.Messaging.Abstractions.Consumers.MessageContextSnapshot.RequestId.init -> void +Vulthil.Messaging.Abstractions.Consumers.MessageContextSnapshot.ResponseAddress.get -> System.Uri? +Vulthil.Messaging.Abstractions.Consumers.MessageContextSnapshot.ResponseAddress.init -> void +Vulthil.Messaging.Abstractions.Consumers.MessageContextSnapshot.RetryCount.get -> int +Vulthil.Messaging.Abstractions.Consumers.MessageContextSnapshot.RetryCount.init -> void +Vulthil.Messaging.Abstractions.Consumers.MessageContextSnapshot.RoutingKey.get -> string! +Vulthil.Messaging.Abstractions.Consumers.MessageContextSnapshot.RoutingKey.init -> void +Vulthil.Messaging.Abstractions.Consumers.MessageContextSnapshot.SourceAddress.get -> System.Uri? +Vulthil.Messaging.Abstractions.Consumers.MessageContextSnapshot.SourceAddress.init -> void Vulthil.Messaging.Abstractions.Consumers.ConsumeDelegate Vulthil.Messaging.Abstractions.Consumers.IConsumeFilter Vulthil.Messaging.Abstractions.Consumers.IConsumeFilter.ConsumeAsync(Vulthil.Messaging.Abstractions.Consumers.IMessageContext! context, Vulthil.Messaging.Abstractions.Consumers.ConsumeDelegate! next) -> System.Threading.Tasks.Task! diff --git a/src/Vulthil.Messaging.RabbitMq/Consumers/MessageContext.cs b/src/Vulthil.Messaging.RabbitMq/Consumers/MessageContext.cs index 71df61d..98699bc 100644 --- a/src/Vulthil.Messaging.RabbitMq/Consumers/MessageContext.cs +++ b/src/Vulthil.Messaging.RabbitMq/Consumers/MessageContext.cs @@ -2,17 +2,16 @@ using Vulthil.Messaging.Abstractions.Consumers; using Vulthil.Messaging.Abstractions.Publishers; using Vulthil.Messaging.RabbitMq.Envelope; -using Vulthil.Messaging.RabbitMq.Sending; namespace Vulthil.Messaging.RabbitMq.Consumers; internal record MessageContext : IMessageContext { - /// The publisher backing . Defaults to for snapshots. - public required IPublisher Publisher { get; init; } + /// The publisher backing , or for a snapshot context not bound to a live transport. + public IPublisher? Publisher { get; init; } - /// The send endpoint provider backing . Defaults to for snapshots. - public required ISendEndpointProvider SendEndpointProvider { get; init; } + /// The send endpoint provider backing , or for a snapshot context not bound to a live transport. + public ISendEndpointProvider? SendEndpointProvider { get; init; } /// public CancellationToken CancellationToken { get; init; } @@ -51,7 +50,7 @@ internal record MessageContext : IMessageContext /// public Task PublishAsync(TMessage message, Func? configure = null) where TMessage : notnull - => Publisher.PublishAsync( + => (Publisher ?? throw SnapshotContextError()).PublishAsync( message, ctx => PropagateAndConfigureAsync(ctx, configure), CancellationToken); @@ -64,7 +63,8 @@ public async Task SendAsync( where TMessage : notnull { ArgumentNullException.ThrowIfNull(destinationAddress); - var endpoint = await SendEndpointProvider.GetSendEndpointAsync(destinationAddress, CancellationToken); + var provider = SendEndpointProvider ?? throw SnapshotContextError(); + var endpoint = await provider.GetSendEndpointAsync(destinationAddress, CancellationToken); await endpoint.SendAsync( message, ctx => PropagateAndConfigureAsync(ctx, configure), @@ -88,28 +88,14 @@ private async ValueTask PropagateAndConfigureAsync(IPublishContext ctx, Func - /// Creates a snapshot with no live transport binding. - /// Used by fault publishing to capture the original context for serialization. - /// - public static MessageContext CreateContext(BasicDeliverEventArgs ea) => - BuildMetadata(ea, NullPublisher.Instance, NullSendEndpointProvider.Instance, cancellationToken: default); - - /// - /// Creates a live bound to the specified transport services and cancellation token. - /// - public static MessageContext CreateContext( - BasicDeliverEventArgs ea, - IPublisher publisher, - ISendEndpointProvider sendEndpointProvider, - CancellationToken cancellationToken) => - BuildMetadata(ea, publisher, sendEndpointProvider, cancellationToken); + private static InvalidOperationException SnapshotContextError() => + new("This message context is a snapshot (e.g. a fault envelope) and is not bound to a live transport."); /// /// Creates a snapshot typed with no live transport binding. /// public static MessageContext CreateContext(TMessage message, BasicDeliverEventArgs ea) => - BuildTypedMetadata(message, ea, NullPublisher.Instance, NullSendEndpointProvider.Instance, cancellationToken: default); + BuildTypedMetadata(message, ea, publisher: null, sendEndpointProvider: null, cancellationToken: default); /// /// Creates a live typed bound to the specified transport services and cancellation token. @@ -118,8 +104,8 @@ public static MessageContext CreateContext(TMessage message, public static MessageContext CreateContext( TMessage message, BasicDeliverEventArgs ea, - IPublisher publisher, - ISendEndpointProvider sendEndpointProvider, + IPublisher? publisher, + ISendEndpointProvider? sendEndpointProvider, CancellationToken cancellationToken) => BuildTypedMetadata(message, ea, publisher, sendEndpointProvider, cancellationToken); @@ -131,15 +117,38 @@ public static MessageContext CreateContext( TMessage message, BasicDeliverEventArgs ea, MessageEnvelope envelope, - IPublisher publisher, - ISendEndpointProvider sendEndpointProvider, + IPublisher? publisher, + ISendEndpointProvider? sendEndpointProvider, CancellationToken cancellationToken) => BuildTypedMetadataFromEnvelope(message, ea, envelope, publisher, sendEndpointProvider, cancellationToken); + /// + /// Builds a serializable of the delivery's transport metadata, + /// used to capture the original context when producing a fault. + /// + public static MessageContextSnapshot CreateSnapshot(BasicDeliverEventArgs ea) + { + var context = BuildMetadata(ea, publisher: null, sendEndpointProvider: null, cancellationToken: default); + return new MessageContextSnapshot + { + MessageId = context.MessageId, + RequestId = context.RequestId, + CorrelationId = context.CorrelationId, + ConversationId = context.ConversationId, + InitiatorId = context.InitiatorId, + SourceAddress = context.SourceAddress, + DestinationAddress = context.DestinationAddress, + ResponseAddress = context.ResponseAddress, + FaultAddress = context.FaultAddress, + RoutingKey = context.RoutingKey, + RetryCount = context.RetryCount, + }; + } + private static MessageContext BuildMetadata( BasicDeliverEventArgs ea, - IPublisher publisher, - ISendEndpointProvider sendEndpointProvider, + IPublisher? publisher, + ISendEndpointProvider? sendEndpointProvider, CancellationToken cancellationToken) { var props = ea.BasicProperties; @@ -171,8 +180,8 @@ private static MessageContext BuildMetadata( private static MessageContext BuildTypedMetadata( TMessage message, BasicDeliverEventArgs ea, - IPublisher publisher, - ISendEndpointProvider sendEndpointProvider, + IPublisher? publisher, + ISendEndpointProvider? sendEndpointProvider, CancellationToken cancellationToken) { var props = ea.BasicProperties; @@ -206,8 +215,8 @@ private static MessageContext BuildTypedMetadataFromEnvelope TMessage message, BasicDeliverEventArgs ea, MessageEnvelope envelope, - IPublisher publisher, - ISendEndpointProvider sendEndpointProvider, + IPublisher? publisher, + ISendEndpointProvider? sendEndpointProvider, CancellationToken cancellationToken) { var props = ea.BasicProperties; diff --git a/src/Vulthil.Messaging.RabbitMq/Consumers/NullPublisher.cs b/src/Vulthil.Messaging.RabbitMq/Consumers/NullPublisher.cs deleted file mode 100644 index d634d4b..0000000 --- a/src/Vulthil.Messaging.RabbitMq/Consumers/NullPublisher.cs +++ /dev/null @@ -1,26 +0,0 @@ -using Vulthil.Messaging.Abstractions.Publishers; - -namespace Vulthil.Messaging.RabbitMq.Consumers; - -/// -/// A placeholder used by snapshots -/// (e.g. ) where no live -/// transport publisher is bound. Calling or on a snapshot is a programmer error. -/// -internal sealed class NullPublisher : IPublisher -{ - public static readonly NullPublisher Instance = new(); - - private NullPublisher() { } - public Task PublishAsync( - TMessage message, - CancellationToken cancellationToken) - where TMessage : notnull => PublishAsync(message, null, cancellationToken); - public Task PublishAsync( - TMessage message, - Func? configureContext = null, - CancellationToken cancellationToken = default) - where TMessage : notnull - => throw new InvalidOperationException( - "This message context is a snapshot (e.g. a fault envelope) and is not bound to a live publisher."); -} diff --git a/src/Vulthil.Messaging.RabbitMq/Consumers/PartitionKeyExtractorFactory.cs b/src/Vulthil.Messaging.RabbitMq/Consumers/PartitionKeyExtractorFactory.cs index 8005f7e..b7026a7 100644 --- a/src/Vulthil.Messaging.RabbitMq/Consumers/PartitionKeyExtractorFactory.cs +++ b/src/Vulthil.Messaging.RabbitMq/Consumers/PartitionKeyExtractorFactory.cs @@ -2,7 +2,6 @@ using RabbitMQ.Client.Events; using Vulthil.Messaging.Abstractions.Consumers; using Vulthil.Messaging.RabbitMq.Envelope; -using Vulthil.Messaging.RabbitMq.Sending; namespace Vulthil.Messaging.RabbitMq.Consumers; @@ -31,7 +30,7 @@ internal static class PartitionKeyExtractorFactory // metadata and the typed message, they do not publish. var context = envelope is null ? MessageContext.CreateContext((TMessage)message, ea) - : MessageContext.CreateContext((TMessage)message, ea, envelope, NullPublisher.Instance, NullSendEndpointProvider.Instance, CancellationToken.None); + : MessageContext.CreateContext((TMessage)message, ea, envelope, publisher: null, sendEndpointProvider: null, CancellationToken.None); return selector(context); }; } diff --git a/src/Vulthil.Messaging.RabbitMq/Consumers/RabbitMqConsumerWorker.cs b/src/Vulthil.Messaging.RabbitMq/Consumers/RabbitMqConsumerWorker.cs index 6355fa0..f1a74d2 100644 --- a/src/Vulthil.Messaging.RabbitMq/Consumers/RabbitMqConsumerWorker.cs +++ b/src/Vulthil.Messaging.RabbitMq/Consumers/RabbitMqConsumerWorker.cs @@ -304,7 +304,7 @@ private async Task PublishFaultIfRequestedAsync(Exception ex, BasicDeliverEventA StackTrace = ex.StackTrace, ExceptionType = ex.GetType().FullName ?? "Unknown", FaultedAt = DateTimeOffset.UtcNow, - OriginalContext = MessageContext.CreateContext(ea) + OriginalContext = MessageContext.CreateSnapshot(ea) }; var faultBody = JsonSerializer.SerializeToUtf8Bytes(fault, _jsonOptions); diff --git a/src/Vulthil.Messaging.RabbitMq/Sending/NullSendEndpointProvider.cs b/src/Vulthil.Messaging.RabbitMq/Sending/NullSendEndpointProvider.cs deleted file mode 100644 index fd414fb..0000000 --- a/src/Vulthil.Messaging.RabbitMq/Sending/NullSendEndpointProvider.cs +++ /dev/null @@ -1,19 +0,0 @@ -using Vulthil.Messaging.Abstractions.Publishers; - -namespace Vulthil.Messaging.RabbitMq.Sending; - -/// -/// A placeholder used by snapshots -/// (e.g. ) where no live -/// transport is bound. Calling on a snapshot is a programmer error. -/// -internal sealed class NullSendEndpointProvider : ISendEndpointProvider -{ - public static readonly NullSendEndpointProvider Instance = new(); - - private NullSendEndpointProvider() { } - - public ValueTask GetSendEndpointAsync(Uri address, CancellationToken cancellationToken = default) - => throw new InvalidOperationException( - "This message context is a snapshot (e.g. a fault envelope) and is not bound to a live transport."); -} diff --git a/tests/Vulthil.Messaging.RabbitMq.Tests/MessageContextSendTests.cs b/tests/Vulthil.Messaging.RabbitMq.Tests/MessageContextSendTests.cs index 7865696..fa3015a 100644 --- a/tests/Vulthil.Messaging.RabbitMq.Tests/MessageContextSendTests.cs +++ b/tests/Vulthil.Messaging.RabbitMq.Tests/MessageContextSendTests.cs @@ -116,6 +116,25 @@ await Assert.ThrowsAsync( () => context.SendAsync(null!, new TestMessage("x"))); } + /// + /// Verifies that a snapshot context (no live transport binding) throws when Send or Publish is attempted. + /// + [Fact] + public async Task SendOrPublishOnASnapshotContextThrows() + { + // Arrange + var props = new BasicProperties { CorrelationId = "c", Headers = new Dictionary() }; + var ea = new BasicDeliverEventArgs( + "consumer-tag", 1, false, "exchange", "routing.key", props, ReadOnlyMemory.Empty); + var snapshot = MessageContext.CreateContext(new TestMessage("payload"), ea); + + // Act & Assert + await Should.ThrowAsync( + () => snapshot.SendAsync(new Uri("queue:dest"), new TestMessage("x"))); + await Should.ThrowAsync( + () => snapshot.PublishAsync(new TestMessage("x"))); + } + private static MessageContext CreateTypedContext( ISendEndpointProvider sendEndpointProvider, string correlationId = "corr-1", @@ -143,6 +162,6 @@ private static MessageContext CreateTypedContext( props, ReadOnlyMemory.Empty); - return MessageContext.CreateContext(new TestMessage("payload"), ea, NullPublisher.Instance, sendEndpointProvider, CancellationToken.None); + return MessageContext.CreateContext(new TestMessage("payload"), ea, null, sendEndpointProvider, CancellationToken.None); } } diff --git a/tests/Vulthil.Messaging.RabbitMq.Tests/MessageContextTests.cs b/tests/Vulthil.Messaging.RabbitMq.Tests/MessageContextTests.cs index ba819ee..8bf4845 100644 --- a/tests/Vulthil.Messaging.RabbitMq.Tests/MessageContextTests.cs +++ b/tests/Vulthil.Messaging.RabbitMq.Tests/MessageContextTests.cs @@ -32,7 +32,7 @@ public void CreateContextShouldMapPropertiesHeadersAndTiming() }); // Act - var context = MessageContext.CreateContext(eventArgs); + var context = MessageContext.CreateContext(new TestMessage("payload"), eventArgs); // Assert context.MessageId.ShouldBe("msg-1"); @@ -60,7 +60,7 @@ public void CreateContextShouldFallbackResponseAddressFromReplyTo() var eventArgs = CreateDeliverEventArgs(replyTo: "reply-queue"); // Act - var context = MessageContext.CreateContext(eventArgs); + var context = MessageContext.CreateContext(new TestMessage("payload"), eventArgs); // Assert context.ResponseAddress.ShouldBe(new Uri("queue:reply-queue")); @@ -78,7 +78,7 @@ public void CreateContextShouldUseDefaultsWhenPropertiesAreMissing() timestamp: 0); // Act - var context = MessageContext.CreateContext(eventArgs); + var context = MessageContext.CreateContext(new TestMessage("payload"), eventArgs); // Assert context.CorrelationId.ShouldBe(string.Empty); @@ -107,6 +107,32 @@ public void CreateContextGenericShouldIncludeTypedMessage() context.CorrelationId.ShouldBe("corr-1"); } + [Fact] + public void CreateSnapshotShouldCaptureTransportMetadata() + { + // Arrange + var eventArgs = CreateDeliverEventArgs( + routingKey: "orders.created", + headers: new Dictionary + { + ["ConversationId"] = Encoding.UTF8.GetBytes("conv-1"), + ["FaultAddress"] = Encoding.UTF8.GetBytes("fault-queue"), + ["x-retry-count"] = 2L, + }); + + // Act + var snapshot = MessageContext.CreateSnapshot(eventArgs); + + // Assert + snapshot.MessageId.ShouldBe("msg-1"); + snapshot.CorrelationId.ShouldBe("corr-1"); + snapshot.RequestId.ShouldBe("corr-1"); + snapshot.RoutingKey.ShouldBe("orders.created"); + snapshot.ConversationId.ShouldBe("conv-1"); + snapshot.FaultAddress.ShouldBe(new Uri("queue:fault-queue")); + snapshot.RetryCount.ShouldBe(2); + } + private static BasicDeliverEventArgs CreateDeliverEventArgs( string routingKey = "route", bool redelivered = false, diff --git a/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqSendEndpointProviderTests.cs b/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqSendEndpointProviderTests.cs index cf396ef..aef78b8 100644 --- a/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqSendEndpointProviderTests.cs +++ b/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqSendEndpointProviderTests.cs @@ -76,18 +76,4 @@ public async Task GetSendEndpointAsyncWithEmptyQueueNameThrows() await Assert.ThrowsAsync( async () => await Target.GetSendEndpointAsync(uri, CancellationToken)); } - - /// - /// Verifies that NullSendEndpointProvider throws when asked for an endpoint. - /// - [Fact] - public async Task NullSendEndpointProviderShouldThrow() - { - // Arrange - var provider = NullSendEndpointProvider.Instance; - - // Act & Assert - await Assert.ThrowsAsync( - async () => await provider.GetSendEndpointAsync(new Uri("queue:any"), CancellationToken)); - } } From f37bfc9796b9bfffef70959d32155676a4de43fc Mon Sep 17 00:00:00 2001 From: Vulthil Date: Wed, 3 Jun 2026 23:41:53 +0200 Subject: [PATCH 23/42] refactor(messaging): promote envelope, pipeline factory, handler kind, rpc fault to public primitives --- .../Consumers/MessageContext.cs | 2 +- .../Consumers/MessageExecutionPlan.cs | 2 +- .../Consumers/MessageHandler.cs | 2 +- .../Consumers/MessageHandlerFactory.cs | 3 +- .../Consumers/MessageTypeCache.cs | 1 + .../Consumers/PartitionKeyExtractorFactory.cs | 2 +- .../Consumers/RabbitMqConsumerWorker.cs | 2 +- .../Envelope/MessageEnvelopeFactory.cs | 1 + .../Requests/ResponseWaiter.cs | 2 +- src/Vulthil.Messaging/PublicAPI.Unshipped.txt | 45 +++++++++++++++++++ .../Transport}/ConsumePipelineFactory.cs | 13 +++++- .../Transport}/HandlerKind.cs | 4 +- .../Transport}/MessageEnvelope.cs | 15 +++---- .../Transport}/RpcFault.cs | 12 ++--- .../ConsumeFilterPipelineTests.cs | 3 +- .../MessageEnvelopeTests.cs | 2 +- .../MessageTypeCacheTests.cs | 3 +- .../PolymorphicDispatchTests.cs | 2 +- .../RabbitMqRequesterTests.cs | 2 +- 19 files changed, 85 insertions(+), 33 deletions(-) rename src/{Vulthil.Messaging.RabbitMq/Consumers => Vulthil.Messaging/Transport}/ConsumePipelineFactory.cs (59%) rename src/{Vulthil.Messaging.RabbitMq/Consumers => Vulthil.Messaging/Transport}/HandlerKind.cs (82%) rename src/{Vulthil.Messaging.RabbitMq/Envelope => Vulthil.Messaging/Transport}/MessageEnvelope.cs (81%) rename src/{Vulthil.Messaging.RabbitMq/Requests => Vulthil.Messaging/Transport}/RpcFault.cs (75%) diff --git a/src/Vulthil.Messaging.RabbitMq/Consumers/MessageContext.cs b/src/Vulthil.Messaging.RabbitMq/Consumers/MessageContext.cs index 98699bc..86cbb0b 100644 --- a/src/Vulthil.Messaging.RabbitMq/Consumers/MessageContext.cs +++ b/src/Vulthil.Messaging.RabbitMq/Consumers/MessageContext.cs @@ -1,7 +1,7 @@ using RabbitMQ.Client.Events; using Vulthil.Messaging.Abstractions.Consumers; using Vulthil.Messaging.Abstractions.Publishers; -using Vulthil.Messaging.RabbitMq.Envelope; +using Vulthil.Messaging.Transport; namespace Vulthil.Messaging.RabbitMq.Consumers; diff --git a/src/Vulthil.Messaging.RabbitMq/Consumers/MessageExecutionPlan.cs b/src/Vulthil.Messaging.RabbitMq/Consumers/MessageExecutionPlan.cs index 3eec84d..b00fae0 100644 --- a/src/Vulthil.Messaging.RabbitMq/Consumers/MessageExecutionPlan.cs +++ b/src/Vulthil.Messaging.RabbitMq/Consumers/MessageExecutionPlan.cs @@ -1,6 +1,6 @@ using RabbitMQ.Client.Events; using Vulthil.Messaging.Queues; -using Vulthil.Messaging.RabbitMq.Envelope; +using Vulthil.Messaging.Transport; namespace Vulthil.Messaging.RabbitMq.Consumers; diff --git a/src/Vulthil.Messaging.RabbitMq/Consumers/MessageHandler.cs b/src/Vulthil.Messaging.RabbitMq/Consumers/MessageHandler.cs index f35ab51..33e0fc3 100644 --- a/src/Vulthil.Messaging.RabbitMq/Consumers/MessageHandler.cs +++ b/src/Vulthil.Messaging.RabbitMq/Consumers/MessageHandler.cs @@ -1,7 +1,7 @@ using RabbitMQ.Client; using RabbitMQ.Client.Events; using Vulthil.Messaging.Queues; -using Vulthil.Messaging.RabbitMq.Envelope; +using Vulthil.Messaging.Transport; namespace Vulthil.Messaging.RabbitMq.Consumers; diff --git a/src/Vulthil.Messaging.RabbitMq/Consumers/MessageHandlerFactory.cs b/src/Vulthil.Messaging.RabbitMq/Consumers/MessageHandlerFactory.cs index e25eeb1..4da7b37 100644 --- a/src/Vulthil.Messaging.RabbitMq/Consumers/MessageHandlerFactory.cs +++ b/src/Vulthil.Messaging.RabbitMq/Consumers/MessageHandlerFactory.cs @@ -5,8 +5,7 @@ using Vulthil.Messaging.Abstractions.Consumers; using Vulthil.Messaging.Abstractions.Publishers; using Vulthil.Messaging.Queues; -using Vulthil.Messaging.RabbitMq.Envelope; -using Vulthil.Messaging.RabbitMq.Requests; +using Vulthil.Messaging.Transport; namespace Vulthil.Messaging.RabbitMq.Consumers; diff --git a/src/Vulthil.Messaging.RabbitMq/Consumers/MessageTypeCache.cs b/src/Vulthil.Messaging.RabbitMq/Consumers/MessageTypeCache.cs index 8228cb8..54947fa 100644 --- a/src/Vulthil.Messaging.RabbitMq/Consumers/MessageTypeCache.cs +++ b/src/Vulthil.Messaging.RabbitMq/Consumers/MessageTypeCache.cs @@ -1,6 +1,7 @@ using System.Collections.Concurrent; using System.Reflection; using Vulthil.Messaging.Queues; +using Vulthil.Messaging.Transport; namespace Vulthil.Messaging.RabbitMq.Consumers; diff --git a/src/Vulthil.Messaging.RabbitMq/Consumers/PartitionKeyExtractorFactory.cs b/src/Vulthil.Messaging.RabbitMq/Consumers/PartitionKeyExtractorFactory.cs index b7026a7..98ffb7e 100644 --- a/src/Vulthil.Messaging.RabbitMq/Consumers/PartitionKeyExtractorFactory.cs +++ b/src/Vulthil.Messaging.RabbitMq/Consumers/PartitionKeyExtractorFactory.cs @@ -1,7 +1,7 @@ using System.Reflection; using RabbitMQ.Client.Events; using Vulthil.Messaging.Abstractions.Consumers; -using Vulthil.Messaging.RabbitMq.Envelope; +using Vulthil.Messaging.Transport; namespace Vulthil.Messaging.RabbitMq.Consumers; diff --git a/src/Vulthil.Messaging.RabbitMq/Consumers/RabbitMqConsumerWorker.cs b/src/Vulthil.Messaging.RabbitMq/Consumers/RabbitMqConsumerWorker.cs index f1a74d2..07e52a5 100644 --- a/src/Vulthil.Messaging.RabbitMq/Consumers/RabbitMqConsumerWorker.cs +++ b/src/Vulthil.Messaging.RabbitMq/Consumers/RabbitMqConsumerWorker.cs @@ -8,9 +8,9 @@ using RabbitMQ.Client.Events; using Vulthil.Messaging.Abstractions.Consumers; using Vulthil.Messaging.Queues; -using Vulthil.Messaging.RabbitMq.Envelope; using Vulthil.Messaging.RabbitMq.Logging; using Vulthil.Messaging.RabbitMq.Telemetry; +using Vulthil.Messaging.Transport; namespace Vulthil.Messaging.RabbitMq.Consumers; diff --git a/src/Vulthil.Messaging.RabbitMq/Envelope/MessageEnvelopeFactory.cs b/src/Vulthil.Messaging.RabbitMq/Envelope/MessageEnvelopeFactory.cs index 536a80a..5277fb5 100644 --- a/src/Vulthil.Messaging.RabbitMq/Envelope/MessageEnvelopeFactory.cs +++ b/src/Vulthil.Messaging.RabbitMq/Envelope/MessageEnvelopeFactory.cs @@ -1,5 +1,6 @@ using System.Text.Json; using Vulthil.Messaging.RabbitMq.Requests; +using Vulthil.Messaging.Transport; namespace Vulthil.Messaging.RabbitMq.Envelope; diff --git a/src/Vulthil.Messaging.RabbitMq/Requests/ResponseWaiter.cs b/src/Vulthil.Messaging.RabbitMq/Requests/ResponseWaiter.cs index f15e3a7..7ed5a28 100644 --- a/src/Vulthil.Messaging.RabbitMq/Requests/ResponseWaiter.cs +++ b/src/Vulthil.Messaging.RabbitMq/Requests/ResponseWaiter.cs @@ -1,5 +1,5 @@ using System.Text.Json; -using Vulthil.Messaging.RabbitMq.Envelope; +using Vulthil.Messaging.Transport; using Vulthil.Results; namespace Vulthil.Messaging.RabbitMq.Requests; diff --git a/src/Vulthil.Messaging/PublicAPI.Unshipped.txt b/src/Vulthil.Messaging/PublicAPI.Unshipped.txt index 07899c3..0f46be6 100644 --- a/src/Vulthil.Messaging/PublicAPI.Unshipped.txt +++ b/src/Vulthil.Messaging/PublicAPI.Unshipped.txt @@ -178,3 +178,48 @@ Vulthil.Messaging.IMessagingConfigurator.UsePartitioner(int partitionC Vulthil.Messaging.IMessagingConfigurator.UsePartitioner(int partitionCount) -> Vulthil.Messaging.IMessagingConfigurator! Vulthil.Messaging.IMessagingConfigurator.UsePartitioner(Vulthil.Messaging.Partitioner! partitioner, System.Func!, string?>! keySelector) -> Vulthil.Messaging.IMessagingConfigurator! Vulthil.Messaging.IMessagingConfigurator.UsePartitioner(Vulthil.Messaging.Partitioner! partitioner) -> Vulthil.Messaging.IMessagingConfigurator! +Vulthil.Messaging.Transport.ConsumePipelineFactory +static Vulthil.Messaging.Transport.ConsumePipelineFactory.Build(System.IServiceProvider! sp, Vulthil.Messaging.Abstractions.Consumers.ConsumeDelegate! terminal) -> Vulthil.Messaging.Abstractions.Consumers.ConsumeDelegate! +Vulthil.Messaging.Transport.HandlerKind +Vulthil.Messaging.Transport.HandlerKind.Consumer = 0 -> Vulthil.Messaging.Transport.HandlerKind +Vulthil.Messaging.Transport.HandlerKind.RequestConsumer = 1 -> Vulthil.Messaging.Transport.HandlerKind +Vulthil.Messaging.Transport.MessageEnvelope +Vulthil.Messaging.Transport.MessageEnvelope.ConversationId.get -> string? +Vulthil.Messaging.Transport.MessageEnvelope.ConversationId.init -> void +Vulthil.Messaging.Transport.MessageEnvelope.CorrelationId.get -> string? +Vulthil.Messaging.Transport.MessageEnvelope.CorrelationId.init -> void +Vulthil.Messaging.Transport.MessageEnvelope.DestinationAddress.get -> string? +Vulthil.Messaging.Transport.MessageEnvelope.DestinationAddress.init -> void +Vulthil.Messaging.Transport.MessageEnvelope.ExpirationTime.get -> System.DateTimeOffset? +Vulthil.Messaging.Transport.MessageEnvelope.ExpirationTime.init -> void +Vulthil.Messaging.Transport.MessageEnvelope.FaultAddress.get -> string? +Vulthil.Messaging.Transport.MessageEnvelope.FaultAddress.init -> void +Vulthil.Messaging.Transport.MessageEnvelope.Headers.get -> System.Collections.Generic.Dictionary? +Vulthil.Messaging.Transport.MessageEnvelope.Headers.init -> void +Vulthil.Messaging.Transport.MessageEnvelope.InitiatorId.get -> string? +Vulthil.Messaging.Transport.MessageEnvelope.InitiatorId.init -> void +Vulthil.Messaging.Transport.MessageEnvelope.Message.get -> System.Text.Json.JsonElement +Vulthil.Messaging.Transport.MessageEnvelope.Message.init -> void +Vulthil.Messaging.Transport.MessageEnvelope.MessageId.get -> string? +Vulthil.Messaging.Transport.MessageEnvelope.MessageId.init -> void +Vulthil.Messaging.Transport.MessageEnvelope.MessageType.get -> System.Uri! +Vulthil.Messaging.Transport.MessageEnvelope.MessageType.init -> void +Vulthil.Messaging.Transport.MessageEnvelope.RequestId.get -> string? +Vulthil.Messaging.Transport.MessageEnvelope.RequestId.init -> void +Vulthil.Messaging.Transport.MessageEnvelope.ResponseAddress.get -> string? +Vulthil.Messaging.Transport.MessageEnvelope.ResponseAddress.init -> void +Vulthil.Messaging.Transport.MessageEnvelope.SentTime.get -> System.DateTimeOffset? +Vulthil.Messaging.Transport.MessageEnvelope.SentTime.init -> void +Vulthil.Messaging.Transport.MessageEnvelope.SourceAddress.get -> string? +Vulthil.Messaging.Transport.MessageEnvelope.SourceAddress.init -> void +Vulthil.Messaging.Transport.RpcFault +const Vulthil.Messaging.Transport.RpcFault.Urn = "urn:message:Vulthil:RpcFault" -> string! +Vulthil.Messaging.Transport.RpcFault.ExceptionType.get -> string! +Vulthil.Messaging.Transport.RpcFault.ExceptionType.init -> void +Vulthil.Messaging.Transport.RpcFault.FaultedAt.get -> System.DateTimeOffset +Vulthil.Messaging.Transport.RpcFault.FaultedAt.init -> void +Vulthil.Messaging.Transport.RpcFault.Message.get -> string! +Vulthil.Messaging.Transport.RpcFault.Message.init -> void +Vulthil.Messaging.Transport.RpcFault.StackTrace.get -> string? +Vulthil.Messaging.Transport.RpcFault.StackTrace.init -> void +static Vulthil.Messaging.Transport.RpcFault.UrnUri.get -> System.Uri! diff --git a/src/Vulthil.Messaging.RabbitMq/Consumers/ConsumePipelineFactory.cs b/src/Vulthil.Messaging/Transport/ConsumePipelineFactory.cs similarity index 59% rename from src/Vulthil.Messaging.RabbitMq/Consumers/ConsumePipelineFactory.cs rename to src/Vulthil.Messaging/Transport/ConsumePipelineFactory.cs index ddd47e6..20fa964 100644 --- a/src/Vulthil.Messaging.RabbitMq/Consumers/ConsumePipelineFactory.cs +++ b/src/Vulthil.Messaging/Transport/ConsumePipelineFactory.cs @@ -1,15 +1,24 @@ using Microsoft.Extensions.DependencyInjection; using Vulthil.Messaging.Abstractions.Consumers; -namespace Vulthil.Messaging.RabbitMq.Consumers; +namespace Vulthil.Messaging.Transport; -internal static class ConsumePipelineFactory +/// +/// Composes the registered instances into a single delegate. +/// Transport authors call this in their consume path to wrap a terminal stage (the consumer invocation) +/// with the configured middleware. +/// +public static class ConsumePipelineFactory { /// /// Composes the registered instances around a terminal /// delegate. The first filter resolved from DI becomes the outermost; the terminal delegate /// runs innermost. /// + /// The message type whose filters are composed. + /// The scope's service provider used to resolve the filters. + /// The innermost delegate, typically the consumer invocation. + /// A delegate that runs all filters in order and then the terminal. public static ConsumeDelegate Build( IServiceProvider sp, ConsumeDelegate terminal) diff --git a/src/Vulthil.Messaging.RabbitMq/Consumers/HandlerKind.cs b/src/Vulthil.Messaging/Transport/HandlerKind.cs similarity index 82% rename from src/Vulthil.Messaging.RabbitMq/Consumers/HandlerKind.cs rename to src/Vulthil.Messaging/Transport/HandlerKind.cs index d74d147..6a8dc95 100644 --- a/src/Vulthil.Messaging.RabbitMq/Consumers/HandlerKind.cs +++ b/src/Vulthil.Messaging/Transport/HandlerKind.cs @@ -1,11 +1,11 @@ using Vulthil.Messaging.Abstractions.Consumers; -namespace Vulthil.Messaging.RabbitMq.Consumers; +namespace Vulthil.Messaging.Transport; /// /// Classifies a dispatch handler by its consumer contract. /// -internal enum HandlerKind +public enum HandlerKind { /// A one-way . Consumer, diff --git a/src/Vulthil.Messaging.RabbitMq/Envelope/MessageEnvelope.cs b/src/Vulthil.Messaging/Transport/MessageEnvelope.cs similarity index 81% rename from src/Vulthil.Messaging.RabbitMq/Envelope/MessageEnvelope.cs rename to src/Vulthil.Messaging/Transport/MessageEnvelope.cs index f805303..b10e546 100644 --- a/src/Vulthil.Messaging.RabbitMq/Envelope/MessageEnvelope.cs +++ b/src/Vulthil.Messaging/Transport/MessageEnvelope.cs @@ -1,19 +1,18 @@ using System.Text.Json; using System.Text.Json.Serialization; -namespace Vulthil.Messaging.RabbitMq.Envelope; +namespace Vulthil.Messaging.Transport; /// -/// On-the-wire wrapper for messages carried by the RabbitMQ transport. The body of every produced -/// message is a serialized ; the receiver falls back to bare JSON only -/// for compatibility with non-Vulthil producers. +/// On-the-wire wrapper for messages carried by a Vulthil transport. The body of every produced +/// message is a serialized ; receivers fall back to bare JSON only for +/// compatibility with non-Vulthil producers. /// /// -/// All metadata that used to live in AMQP BasicProperties + ad-hoc headers is promoted to -/// first-class envelope fields. AMQP properties are still mirrored for broker tooling -/// (CorrelationId, MessageId, Type) and trace propagation, but the envelope is the source of truth. +/// All metadata that would otherwise live in transport-specific headers is promoted to first-class +/// envelope fields, so the envelope is the source of truth and the wire format is transport-independent. /// -internal sealed record MessageEnvelope +public sealed record MessageEnvelope { /// The unique identifier for this message. [JsonPropertyName("messageId")] diff --git a/src/Vulthil.Messaging.RabbitMq/Requests/RpcFault.cs b/src/Vulthil.Messaging/Transport/RpcFault.cs similarity index 75% rename from src/Vulthil.Messaging.RabbitMq/Requests/RpcFault.cs rename to src/Vulthil.Messaging/Transport/RpcFault.cs index 4ef5488..a256923 100644 --- a/src/Vulthil.Messaging.RabbitMq/Requests/RpcFault.cs +++ b/src/Vulthil.Messaging/Transport/RpcFault.cs @@ -1,19 +1,19 @@ using System.Text.Json.Serialization; -namespace Vulthil.Messaging.RabbitMq.Requests; +namespace Vulthil.Messaging.Transport; /// -/// Wire payload for an RPC failure reply. Carried as the message of a -/// whose messageType is . Property -/// names are fixed (camelCase) so the reply round-trips regardless of the configured JSON naming policy. +/// Wire payload for an RPC failure reply. Carried as the message of a +/// whose messageType is . Property names are fixed (camelCase) so the reply +/// round-trips regardless of the configured JSON naming policy. /// -internal sealed record RpcFault +public sealed record RpcFault { /// The stable wire URN identifying an RPC fault reply payload. public const string Urn = "urn:message:Vulthil:RpcFault"; /// The as a , for comparison against an envelope's message type. - public static readonly Uri UrnUri = new(Urn); + public static Uri UrnUri { get; } = new(Urn); /// The exception message describing the failure. [JsonPropertyName("message")] diff --git a/tests/Vulthil.Messaging.RabbitMq.Tests/ConsumeFilterPipelineTests.cs b/tests/Vulthil.Messaging.RabbitMq.Tests/ConsumeFilterPipelineTests.cs index 9b1b2fa..5cab3c6 100644 --- a/tests/Vulthil.Messaging.RabbitMq.Tests/ConsumeFilterPipelineTests.cs +++ b/tests/Vulthil.Messaging.RabbitMq.Tests/ConsumeFilterPipelineTests.cs @@ -6,8 +6,7 @@ using Vulthil.Messaging.Abstractions.Publishers; using Vulthil.Messaging.Queues; using Vulthil.Messaging.RabbitMq.Consumers; -using Vulthil.Messaging.RabbitMq.Envelope; -using Vulthil.Messaging.RabbitMq.Requests; +using Vulthil.Messaging.Transport; using Vulthil.xUnit; namespace Vulthil.Messaging.RabbitMq.Tests; diff --git a/tests/Vulthil.Messaging.RabbitMq.Tests/MessageEnvelopeTests.cs b/tests/Vulthil.Messaging.RabbitMq.Tests/MessageEnvelopeTests.cs index 3ca70af..ab2911b 100644 --- a/tests/Vulthil.Messaging.RabbitMq.Tests/MessageEnvelopeTests.cs +++ b/tests/Vulthil.Messaging.RabbitMq.Tests/MessageEnvelopeTests.cs @@ -1,5 +1,5 @@ using System.Text.Json; -using Vulthil.Messaging.RabbitMq.Envelope; +using Vulthil.Messaging.Transport; using Vulthil.xUnit; namespace Vulthil.Messaging.RabbitMq.Tests; diff --git a/tests/Vulthil.Messaging.RabbitMq.Tests/MessageTypeCacheTests.cs b/tests/Vulthil.Messaging.RabbitMq.Tests/MessageTypeCacheTests.cs index dee9556..1a4e67d 100644 --- a/tests/Vulthil.Messaging.RabbitMq.Tests/MessageTypeCacheTests.cs +++ b/tests/Vulthil.Messaging.RabbitMq.Tests/MessageTypeCacheTests.cs @@ -4,8 +4,7 @@ using Vulthil.Messaging.Abstractions.Consumers; using Vulthil.Messaging.Queues; using Vulthil.Messaging.RabbitMq.Consumers; -using Vulthil.Messaging.RabbitMq.Envelope; -using Vulthil.Messaging.RabbitMq.Requests; +using Vulthil.Messaging.Transport; using Vulthil.xUnit; namespace Vulthil.Messaging.RabbitMq.Tests; diff --git a/tests/Vulthil.Messaging.RabbitMq.Tests/PolymorphicDispatchTests.cs b/tests/Vulthil.Messaging.RabbitMq.Tests/PolymorphicDispatchTests.cs index 3ff9514..d331c5c 100644 --- a/tests/Vulthil.Messaging.RabbitMq.Tests/PolymorphicDispatchTests.cs +++ b/tests/Vulthil.Messaging.RabbitMq.Tests/PolymorphicDispatchTests.cs @@ -4,7 +4,7 @@ using Vulthil.Messaging.Abstractions.Publishers; using Vulthil.Messaging.Queues; using Vulthil.Messaging.RabbitMq.Consumers; -using Vulthil.Messaging.RabbitMq.Envelope; +using Vulthil.Messaging.Transport; using Vulthil.xUnit; namespace Vulthil.Messaging.RabbitMq.Tests; diff --git a/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqRequesterTests.cs b/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqRequesterTests.cs index a017919..8122ff4 100644 --- a/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqRequesterTests.cs +++ b/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqRequesterTests.cs @@ -1,9 +1,9 @@ using System.Diagnostics; using System.Text.Json; using RabbitMQ.Client; -using Vulthil.Messaging.RabbitMq.Envelope; using Vulthil.Messaging.RabbitMq.Publishing; using Vulthil.Messaging.RabbitMq.Requests; +using Vulthil.Messaging.Transport; using Vulthil.xUnit; namespace Vulthil.Messaging.RabbitMq.Tests; From dc4197c5fd0c3033dd9795712b9d4a4c4cee580a Mon Sep 17 00:00:00 2001 From: Vulthil Date: Thu, 4 Jun 2026 20:46:03 +0200 Subject: [PATCH 24/42] refactor(messaging): extract generic MessageExecutionRegistry; RabbitMq consumes it --- .../Consumers/MessageExecutionPlan.cs | 29 ---- .../Consumers/MessageHandlerFactory.cs | 2 +- .../Consumers/MessageTypeCache.cs | 145 ++++-------------- .../Consumers/RabbitMqConsumerWorker.cs | 6 +- .../Consumers/RabbitMqHandlerFactory.cs | 42 +++++ .../Consumers/RabbitMqPlan.cs | 40 +++++ src/Vulthil.Messaging/PublicAPI.Unshipped.txt | 27 ++++ .../Transport/HandlerEntry.cs | 12 ++ .../Transport/IMessageHandlerFactory.cs | 32 ++++ .../Transport/MessageExecutionPlan.cs | 32 ++++ .../Transport/MessageExecutionRegistry.cs | 143 +++++++++++++++++ 11 files changed, 363 insertions(+), 147 deletions(-) delete mode 100644 src/Vulthil.Messaging.RabbitMq/Consumers/MessageExecutionPlan.cs create mode 100644 src/Vulthil.Messaging.RabbitMq/Consumers/RabbitMqHandlerFactory.cs create mode 100644 src/Vulthil.Messaging.RabbitMq/Consumers/RabbitMqPlan.cs create mode 100644 src/Vulthil.Messaging/Transport/HandlerEntry.cs create mode 100644 src/Vulthil.Messaging/Transport/IMessageHandlerFactory.cs create mode 100644 src/Vulthil.Messaging/Transport/MessageExecutionPlan.cs create mode 100644 src/Vulthil.Messaging/Transport/MessageExecutionRegistry.cs diff --git a/src/Vulthil.Messaging.RabbitMq/Consumers/MessageExecutionPlan.cs b/src/Vulthil.Messaging.RabbitMq/Consumers/MessageExecutionPlan.cs deleted file mode 100644 index b00fae0..0000000 --- a/src/Vulthil.Messaging.RabbitMq/Consumers/MessageExecutionPlan.cs +++ /dev/null @@ -1,29 +0,0 @@ -using RabbitMQ.Client.Events; -using Vulthil.Messaging.Queues; -using Vulthil.Messaging.Transport; - -namespace Vulthil.Messaging.RabbitMq.Consumers; - -internal sealed record MessageExecutionPlan(MessageType MessageType, Uri Urn) -{ - /// - /// The set of handlers that should run when a message of is delivered. - /// The broker is authoritative for delivery (queue-binding filter); every handler in this list runs on every delivery. - /// - public List Handlers { get; } = []; - - /// - /// The partitioner whose lanes serialize same-key deliveries of this message type, or - /// when the type is not partitioned. - /// - public Partitioner? Partitioner { get; init; } - - /// - /// Extracts the partition key from a delivered message (and its transport metadata), or - /// when the type is not partitioned. - /// - public Func? PartitionKeyExtractor { get; init; } - - /// Gets a value indicating whether deliveries of this message type are partitioned for ordered processing. - public bool IsPartitioned => Partitioner is not null && PartitionKeyExtractor is not null; -} diff --git a/src/Vulthil.Messaging.RabbitMq/Consumers/MessageHandlerFactory.cs b/src/Vulthil.Messaging.RabbitMq/Consumers/MessageHandlerFactory.cs index 4da7b37..3ad32ea 100644 --- a/src/Vulthil.Messaging.RabbitMq/Consumers/MessageHandlerFactory.cs +++ b/src/Vulthil.Messaging.RabbitMq/Consumers/MessageHandlerFactory.cs @@ -12,7 +12,7 @@ namespace Vulthil.Messaging.RabbitMq.Consumers; /// /// Builds instances from open-generic consumer/message type pairs. /// The factory methods are the single source of truth for the dispatch closure shape; reflection-driven -/// callers in bind to these via typed delegates so signature drift fails at startup. +/// callers in bind to these via typed delegates so signature drift fails at startup. /// internal static class MessageHandlerFactory { diff --git a/src/Vulthil.Messaging.RabbitMq/Consumers/MessageTypeCache.cs b/src/Vulthil.Messaging.RabbitMq/Consumers/MessageTypeCache.cs index 54947fa..dfc35dc 100644 --- a/src/Vulthil.Messaging.RabbitMq/Consumers/MessageTypeCache.cs +++ b/src/Vulthil.Messaging.RabbitMq/Consumers/MessageTypeCache.cs @@ -1,139 +1,56 @@ -using System.Collections.Concurrent; -using System.Reflection; using Vulthil.Messaging.Queues; using Vulthil.Messaging.Transport; namespace Vulthil.Messaging.RabbitMq.Consumers; +/// +/// RabbitMQ adapter over . Delegates plan assembly to the core +/// registry and decorates each resolved plan with a carrying the AMQP partition key +/// extractor. Wrappers are built once per plan (keyed by URN) during registration, so delivery-time lookups stay +/// read-only. +/// internal sealed class MessageTypeCache { - private readonly IMessageConfigurationProvider _provider; - private readonly Dictionary _plansByUrn = []; - private readonly Dictionary _plansByFullName = new(StringComparer.Ordinal); - private readonly ConcurrentDictionary<(Type Consumer, Type Message), Func> _consumerFactoryCache = new(); - private readonly ConcurrentDictionary<(Type Consumer, Type Request, Type Response), Func> _requestConsumerFactoryCache = new(); - - private static readonly MethodInfo _forConsumerMethod = typeof(MessageHandlerFactory) - .GetMethod(nameof(MessageHandlerFactory.ForConsumer), BindingFlags.Public | BindingFlags.Static) - ?? throw new InvalidOperationException($"{nameof(MessageHandlerFactory)}.{nameof(MessageHandlerFactory.ForConsumer)} not found."); - private static readonly MethodInfo _forRequestConsumerMethod = typeof(MessageHandlerFactory) - .GetMethod(nameof(MessageHandlerFactory.ForRequestConsumer), BindingFlags.Public | BindingFlags.Static) - ?? throw new InvalidOperationException($"{nameof(MessageHandlerFactory)}.{nameof(MessageHandlerFactory.ForRequestConsumer)} not found."); + private readonly MessageExecutionRegistry _registry; + private readonly Dictionary _wrappers = []; public MessageTypeCache(IMessageConfigurationProvider provider) - { - _provider = provider; - } + => _registry = new MessageExecutionRegistry(provider, new RabbitMqHandlerFactory()); public void RegisterQueue(QueueDefinition queue) { - var effectiveSubscriptions = new HashSet(queue.Subscriptions.Select(s => s.MessageType)); - var concreteRegistrationTypes = queue.Registrations - .Select(r => r.MessageType) - .Where(m => m.Type is { IsAbstract: false, IsInterface: false }); - foreach (var concrete in concreteRegistrationTypes) - { - effectiveSubscriptions.Add(concrete); - } - - foreach (var subscription in effectiveSubscriptions) + _registry.RegisterQueue(queue); + foreach (var plan in _registry.Plans.Where(plan => !_wrappers.ContainsKey(plan.Urn))) { - var concreteType = subscription.Type; - var plan = GetOrAddPlan(subscription); - - foreach (var registration in queue.Registrations) - { - if (!registration.MessageType.Type.IsAssignableFrom(concreteType)) - { - continue; - } - - if (registration is RequestConsumerRegistration rpc) - { - if (plan.Handlers.Any(h => h.Kind == HandlerKind.RequestConsumer)) - { - throw new InvalidOperationException( - $"Queue '{queue.Name}' already has a request consumer registered for message type '{subscription.Name}'. " + - "A message type can have at most one request consumer per queue, since multiple responses would be ambiguous."); - } - - var rpcFactory = GetRequestConsumerFactory(rpc.ConsumerType.Type, rpc.MessageType.Type, rpc.ResponseType); - plan.Handlers.Add(rpcFactory(rpc.RetryPolicy)); - } - else - { - var consumerFactory = GetConsumerFactory(registration.ConsumerType.Type, registration.MessageType.Type); - plan.Handlers.Add(consumerFactory(registration.RetryPolicy)); - } - } + _wrappers[plan.Urn] = BuildWrapper(plan); } } - private Func GetConsumerFactory(Type consumerType, Type messageType) - => _consumerFactoryCache.GetOrAdd((consumerType, messageType), static key => - _forConsumerMethod - .MakeGenericMethod(key.Consumer, key.Message) - .CreateDelegate>()); + /// + public bool IsQueuePartitioned(QueueDefinition queue) => _registry.IsQueuePartitioned(queue); - private Func GetRequestConsumerFactory(Type consumerType, Type requestType, Type responseType) - => _requestConsumerFactoryCache.GetOrAdd((consumerType, requestType, responseType), static key => - _forRequestConsumerMethod - .MakeGenericMethod(key.Consumer, key.Request, key.Response) - .CreateDelegate>()); + /// Resolves a wrapped plan from the wire URN (envelope path). Returns when no plan matches. + public RabbitMqPlan? GetPlanByUrn(Uri urn) => _wrappers.GetValueOrDefault(urn); - private MessageExecutionPlan GetOrAddPlan(MessageType messageType) + /// Resolves a wrapped plan from the CLR full type name (bare-JSON compat path). Returns when no plan matches. + public RabbitMqPlan? GetPlanByFullName(string fullName) { - var urn = _provider.GetUrn(messageType.Type); - if (_plansByUrn.TryGetValue(urn, out var existing)) - { - return existing; - } - - var partition = _provider.GetPartition(messageType.Type); - var plan = new MessageExecutionPlan(messageType, urn) - { - Partitioner = partition?.Partitioner, - PartitionKeyExtractor = partition is null - ? null - : PartitionKeyExtractorFactory.Build(messageType.Type, partition.KeySelector), - }; - _plansByUrn[urn] = plan; - _plansByFullName[messageType.Name] = plan; - return plan; + var core = _registry.GetPlanByFullName(fullName); + return core is null ? null : _wrappers.GetValueOrDefault(core.Urn); } - /// - /// Indicates whether any concrete message type subscribed or consumed by is - /// partitioned, read directly from . Because it - /// does not depend on built plans it is valid both during topology setup (which precedes - /// ) and afterwards. Drives ordered single dispatch and the single-active-consumer - /// queue argument. - /// - public bool IsQueuePartitioned(QueueDefinition queue) - => queue.Subscriptions.Any(s => _provider.GetPartition(s.MessageType.Type) is not null) - || queue.Registrations.Any(r => - r.MessageType.Type is { IsAbstract: false, IsInterface: false } - && _provider.GetPartition(r.MessageType.Type) is not null); - - /// - /// Resolves a plan from the wire URN (envelope path). Returns when no plan matches. - /// - public MessageExecutionPlan? GetPlanByUrn(Uri urn) => _plansByUrn.GetValueOrDefault(urn); - - /// - /// Resolves a plan from the CLR full type name (bare-JSON compat path). Returns when no plan matches. - /// - public MessageExecutionPlan? GetPlanByFullName(string fullName) => _plansByFullName.GetValueOrDefault(fullName); + /// Convenience lookup used by the bare-JSON receive path and tests: tries URN parsing first, falls back to CLR full name. + public RabbitMqPlan? GetPlan(string key) + { + var core = _registry.GetPlan(key); + return core is null ? null : _wrappers.GetValueOrDefault(core.Urn); + } - /// - /// Convenience lookup used by tests and the bare-JSON receive path: tries URN parsing first, falls back to CLR full name. - /// - public MessageExecutionPlan? GetPlan(string key) + private static RabbitMqPlan BuildWrapper(MessageExecutionPlan plan) { - if (Uri.TryCreate(key, UriKind.Absolute, out var urn) && _plansByUrn.TryGetValue(urn, out var plan)) - { - return plan; - } - return _plansByFullName.GetValueOrDefault(key); + var extractor = plan.Partition is null + ? null + : PartitionKeyExtractorFactory.Build(plan.MessageType.Type, plan.Partition.KeySelector); + return new RabbitMqPlan(plan, extractor); } } diff --git a/src/Vulthil.Messaging.RabbitMq/Consumers/RabbitMqConsumerWorker.cs b/src/Vulthil.Messaging.RabbitMq/Consumers/RabbitMqConsumerWorker.cs index 07e52a5..d3a0dba 100644 --- a/src/Vulthil.Messaging.RabbitMq/Consumers/RabbitMqConsumerWorker.cs +++ b/src/Vulthil.Messaging.RabbitMq/Consumers/RabbitMqConsumerWorker.cs @@ -323,7 +323,7 @@ private async Task PublishFaultIfRequestedAsync(Exception ex, BasicDeliverEventA } } - private static RetryPolicyDefinition? GetPolicy(MessageExecutionPlan? plan, QueueDefinition queue) + private static RetryPolicyDefinition? GetPolicy(RabbitMqPlan? plan, QueueDefinition queue) { if (plan is not null) { @@ -398,7 +398,7 @@ internal static BasicDeliverEventArgs WithRetryCount(BasicDeliverEventArgs ea, i return new PreparedDelivery(plan, message, envelope, diagnosticTypeName); } - private async Task DispatchHandlersAsync(MessageExecutionPlan plan, object message, BasicDeliverEventArgs ea, MessageEnvelope? envelope) + private async Task DispatchHandlersAsync(RabbitMqPlan plan, object message, BasicDeliverEventArgs ea, MessageEnvelope? envelope) { await using var scope = _serviceScopeFactory.CreateAsyncScope(); @@ -473,5 +473,5 @@ public async ValueTask DisposeAsync() } } - private sealed record PreparedDelivery(MessageExecutionPlan Plan, object Message, MessageEnvelope? Envelope, string DiagnosticTypeName); + private sealed record PreparedDelivery(RabbitMqPlan Plan, object Message, MessageEnvelope? Envelope, string DiagnosticTypeName); } diff --git a/src/Vulthil.Messaging.RabbitMq/Consumers/RabbitMqHandlerFactory.cs b/src/Vulthil.Messaging.RabbitMq/Consumers/RabbitMqHandlerFactory.cs new file mode 100644 index 0000000..b4111cf --- /dev/null +++ b/src/Vulthil.Messaging.RabbitMq/Consumers/RabbitMqHandlerFactory.cs @@ -0,0 +1,42 @@ +using System.Collections.Concurrent; +using System.Reflection; +using Vulthil.Messaging.Queues; +using Vulthil.Messaging.Transport; + +namespace Vulthil.Messaging.RabbitMq.Consumers; + +/// +/// RabbitMQ implementation of . Binds the open-generic +/// factory methods to concrete type arguments via cached typed delegates, +/// so the reflection cost is paid once per consumer/message shape. +/// +internal sealed class RabbitMqHandlerFactory : IMessageHandlerFactory +{ + private static readonly MethodInfo _forConsumerMethod = typeof(MessageHandlerFactory) + .GetMethod(nameof(MessageHandlerFactory.ForConsumer), BindingFlags.Public | BindingFlags.Static) + ?? throw new InvalidOperationException($"{nameof(MessageHandlerFactory)}.{nameof(MessageHandlerFactory.ForConsumer)} not found."); + private static readonly MethodInfo _forRequestConsumerMethod = typeof(MessageHandlerFactory) + .GetMethod(nameof(MessageHandlerFactory.ForRequestConsumer), BindingFlags.Public | BindingFlags.Static) + ?? throw new InvalidOperationException($"{nameof(MessageHandlerFactory)}.{nameof(MessageHandlerFactory.ForRequestConsumer)} not found."); + + private readonly ConcurrentDictionary<(Type Consumer, Type Message), Func> _consumerFactoryCache = new(); + private readonly ConcurrentDictionary<(Type Consumer, Type Request, Type Response), Func> _requestConsumerFactoryCache = new(); + + public HandlerEntry ForConsumer(Type consumerType, Type messageType, RetryPolicyDefinition? retryPolicy) + { + var factory = _consumerFactoryCache.GetOrAdd((consumerType, messageType), static key => + _forConsumerMethod + .MakeGenericMethod(key.Consumer, key.Message) + .CreateDelegate>()); + return new HandlerEntry(factory(retryPolicy), HandlerKind.Consumer); + } + + public HandlerEntry ForRequestConsumer(Type consumerType, Type requestType, Type responseType, RetryPolicyDefinition? retryPolicy) + { + var factory = _requestConsumerFactoryCache.GetOrAdd((consumerType, requestType, responseType), static key => + _forRequestConsumerMethod + .MakeGenericMethod(key.Consumer, key.Request, key.Response) + .CreateDelegate>()); + return new HandlerEntry(factory(retryPolicy), HandlerKind.RequestConsumer); + } +} diff --git a/src/Vulthil.Messaging.RabbitMq/Consumers/RabbitMqPlan.cs b/src/Vulthil.Messaging.RabbitMq/Consumers/RabbitMqPlan.cs new file mode 100644 index 0000000..fb9cb2c --- /dev/null +++ b/src/Vulthil.Messaging.RabbitMq/Consumers/RabbitMqPlan.cs @@ -0,0 +1,40 @@ +using System.Collections.ObjectModel; +using RabbitMQ.Client.Events; +using Vulthil.Messaging.Queues; +using Vulthil.Messaging.Transport; + +namespace Vulthil.Messaging.RabbitMq.Consumers; + +/// +/// RabbitMQ view over a core : surfaces the handler list and the +/// resolved plus the AMQP-aware partition key extractor the worker needs to lane +/// partitioned deliveries. Wraps the core plan by reference, so handlers added after construction are observed. +/// +internal sealed class RabbitMqPlan +{ + private readonly MessageExecutionPlan _core; + + public RabbitMqPlan(MessageExecutionPlan core, Func? partitionKeyExtractor) + { + _core = core; + PartitionKeyExtractor = partitionKeyExtractor; + } + + /// The concrete message type this plan dispatches. + public MessageType MessageType => _core.MessageType; + + /// The handlers that run on every delivery of . + public Collection Handlers => _core.Handlers; + + /// The partitioner whose lanes serialize same-key deliveries, or when not partitioned. + public Partitioner? Partitioner => _core.Partition?.Partitioner; + + /// + /// Extracts the partition key from a delivered message (and its transport metadata), or + /// when the type is not partitioned. + /// + public Func? PartitionKeyExtractor { get; } + + /// Gets a value indicating whether deliveries of this message type are partitioned for ordered processing. + public bool IsPartitioned => Partitioner is not null && PartitionKeyExtractor is not null; +} diff --git a/src/Vulthil.Messaging/PublicAPI.Unshipped.txt b/src/Vulthil.Messaging/PublicAPI.Unshipped.txt index 0f46be6..a20ffd0 100644 --- a/src/Vulthil.Messaging/PublicAPI.Unshipped.txt +++ b/src/Vulthil.Messaging/PublicAPI.Unshipped.txt @@ -180,9 +180,18 @@ Vulthil.Messaging.IMessagingConfigurator.UsePartitioner(Vulthil.Messag Vulthil.Messaging.IMessagingConfigurator.UsePartitioner(Vulthil.Messaging.Partitioner! partitioner) -> Vulthil.Messaging.IMessagingConfigurator! Vulthil.Messaging.Transport.ConsumePipelineFactory static Vulthil.Messaging.Transport.ConsumePipelineFactory.Build(System.IServiceProvider! sp, Vulthil.Messaging.Abstractions.Consumers.ConsumeDelegate! terminal) -> Vulthil.Messaging.Abstractions.Consumers.ConsumeDelegate! +Vulthil.Messaging.Transport.HandlerEntry +Vulthil.Messaging.Transport.HandlerEntry.Handler.get -> THandler +Vulthil.Messaging.Transport.HandlerEntry.Handler.init -> void +Vulthil.Messaging.Transport.HandlerEntry.HandlerEntry(THandler Handler, Vulthil.Messaging.Transport.HandlerKind Kind) -> void +Vulthil.Messaging.Transport.HandlerEntry.Kind.get -> Vulthil.Messaging.Transport.HandlerKind +Vulthil.Messaging.Transport.HandlerEntry.Kind.init -> void Vulthil.Messaging.Transport.HandlerKind Vulthil.Messaging.Transport.HandlerKind.Consumer = 0 -> Vulthil.Messaging.Transport.HandlerKind Vulthil.Messaging.Transport.HandlerKind.RequestConsumer = 1 -> Vulthil.Messaging.Transport.HandlerKind +Vulthil.Messaging.Transport.IMessageHandlerFactory +Vulthil.Messaging.Transport.IMessageHandlerFactory.ForConsumer(System.Type! consumerType, System.Type! messageType, Vulthil.Messaging.Queues.RetryPolicyDefinition? retryPolicy) -> Vulthil.Messaging.Transport.HandlerEntry! +Vulthil.Messaging.Transport.IMessageHandlerFactory.ForRequestConsumer(System.Type! consumerType, System.Type! requestType, System.Type! responseType, Vulthil.Messaging.Queues.RetryPolicyDefinition? retryPolicy) -> Vulthil.Messaging.Transport.HandlerEntry! Vulthil.Messaging.Transport.MessageEnvelope Vulthil.Messaging.Transport.MessageEnvelope.ConversationId.get -> string? Vulthil.Messaging.Transport.MessageEnvelope.ConversationId.init -> void @@ -212,6 +221,24 @@ Vulthil.Messaging.Transport.MessageEnvelope.SentTime.get -> System.DateTimeOffse Vulthil.Messaging.Transport.MessageEnvelope.SentTime.init -> void Vulthil.Messaging.Transport.MessageEnvelope.SourceAddress.get -> string? Vulthil.Messaging.Transport.MessageEnvelope.SourceAddress.init -> void +Vulthil.Messaging.Transport.MessageExecutionPlan +Vulthil.Messaging.Transport.MessageExecutionPlan.Handlers.get -> System.Collections.ObjectModel.Collection! +Vulthil.Messaging.Transport.MessageExecutionPlan.IsPartitioned.get -> bool +Vulthil.Messaging.Transport.MessageExecutionPlan.MessageExecutionPlan(Vulthil.Messaging.Queues.MessageType! MessageType, System.Uri! Urn) -> void +Vulthil.Messaging.Transport.MessageExecutionPlan.MessageType.get -> Vulthil.Messaging.Queues.MessageType! +Vulthil.Messaging.Transport.MessageExecutionPlan.MessageType.init -> void +Vulthil.Messaging.Transport.MessageExecutionPlan.Partition.get -> Vulthil.Messaging.PartitionSpec? +Vulthil.Messaging.Transport.MessageExecutionPlan.Partition.init -> void +Vulthil.Messaging.Transport.MessageExecutionPlan.Urn.get -> System.Uri! +Vulthil.Messaging.Transport.MessageExecutionPlan.Urn.init -> void +Vulthil.Messaging.Transport.MessageExecutionRegistry +Vulthil.Messaging.Transport.MessageExecutionRegistry.GetPlan(string! key) -> Vulthil.Messaging.Transport.MessageExecutionPlan? +Vulthil.Messaging.Transport.MessageExecutionRegistry.GetPlanByFullName(string! fullName) -> Vulthil.Messaging.Transport.MessageExecutionPlan? +Vulthil.Messaging.Transport.MessageExecutionRegistry.GetPlanByUrn(System.Uri! urn) -> Vulthil.Messaging.Transport.MessageExecutionPlan? +Vulthil.Messaging.Transport.MessageExecutionRegistry.IsQueuePartitioned(Vulthil.Messaging.Queues.QueueDefinition! queue) -> bool +Vulthil.Messaging.Transport.MessageExecutionRegistry.MessageExecutionRegistry(Vulthil.Messaging.IMessageConfigurationProvider! provider, Vulthil.Messaging.Transport.IMessageHandlerFactory! handlerFactory) -> void +Vulthil.Messaging.Transport.MessageExecutionRegistry.Plans.get -> System.Collections.Generic.IReadOnlyCollection!>! +Vulthil.Messaging.Transport.MessageExecutionRegistry.RegisterQueue(Vulthil.Messaging.Queues.QueueDefinition! queue) -> void Vulthil.Messaging.Transport.RpcFault const Vulthil.Messaging.Transport.RpcFault.Urn = "urn:message:Vulthil:RpcFault" -> string! Vulthil.Messaging.Transport.RpcFault.ExceptionType.get -> string! diff --git a/src/Vulthil.Messaging/Transport/HandlerEntry.cs b/src/Vulthil.Messaging/Transport/HandlerEntry.cs new file mode 100644 index 0000000..ee7fbe2 --- /dev/null +++ b/src/Vulthil.Messaging/Transport/HandlerEntry.cs @@ -0,0 +1,12 @@ +namespace Vulthil.Messaging.Transport; + +/// +/// Pairs a transport-specific dispatch handler with the consumer contract it implements. Returned by +/// so can +/// enforce request-consumer uniqueness without inspecting the opaque value. +/// +/// The transport-specific handler type produced by the factory. +/// The transport-specific handler that runs when a matching message is delivered. +/// The consumer contract the handler implements. +public sealed record HandlerEntry(THandler Handler, HandlerKind Kind) + where THandler : notnull; diff --git a/src/Vulthil.Messaging/Transport/IMessageHandlerFactory.cs b/src/Vulthil.Messaging/Transport/IMessageHandlerFactory.cs new file mode 100644 index 0000000..5e3cd54 --- /dev/null +++ b/src/Vulthil.Messaging/Transport/IMessageHandlerFactory.cs @@ -0,0 +1,32 @@ +using Vulthil.Messaging.Queues; + +namespace Vulthil.Messaging.Transport; + +/// +/// Builds transport-specific dispatch handlers from the open-generic consumer/message type pairs collected +/// during registration. A transport implements this to bind a registration to its own delivery closure; +/// calls it while assembling execution plans. +/// +/// The transport-specific handler type produced by the factory. +public interface IMessageHandlerFactory + where THandler : notnull +{ + /// + /// Builds a handler for a one-way consumer. + /// + /// The CLR type of the IConsumer<TMessage> implementation. + /// The CLR type of the consumed message. + /// The retry policy to apply, or to inherit the queue default. + /// The handler paired with . + HandlerEntry ForConsumer(Type consumerType, Type messageType, RetryPolicyDefinition? retryPolicy); + + /// + /// Builds a handler for a request/reply consumer. + /// + /// The CLR type of the IRequestConsumer<TRequest, TResponse> implementation. + /// The CLR type of the consumed request. + /// The CLR type of the produced response. + /// The retry policy to apply, or to inherit the queue default. + /// The handler paired with . + HandlerEntry ForRequestConsumer(Type consumerType, Type requestType, Type responseType, RetryPolicyDefinition? retryPolicy); +} diff --git a/src/Vulthil.Messaging/Transport/MessageExecutionPlan.cs b/src/Vulthil.Messaging/Transport/MessageExecutionPlan.cs new file mode 100644 index 0000000..c468eff --- /dev/null +++ b/src/Vulthil.Messaging/Transport/MessageExecutionPlan.cs @@ -0,0 +1,32 @@ +using System.Collections.ObjectModel; +using Vulthil.Messaging.Queues; + +namespace Vulthil.Messaging.Transport; + +/// +/// Transport-agnostic execution plan for a single concrete message type: the ordered set of handlers that +/// run on every delivery, plus the partition specification when the type is partitioned for ordered +/// consumption. Built and keyed by . +/// +/// The transport-specific handler type stored in the plan. +/// The concrete message type this plan dispatches. +/// The stable wire URN for . +public sealed record MessageExecutionPlan(MessageType MessageType, Uri Urn) + where THandler : notnull +{ + /// + /// The set of handlers that should run when a message of is delivered. + /// Every handler in this list runs on every delivery; the broker is authoritative for delivery filtering. + /// + public Collection Handlers { get; } = []; + + /// + /// The partition specification whose lanes serialize same-key deliveries of this message type, or + /// when the type is not partitioned. Partition key extraction stays transport-side + /// because it needs the transport's delivery type, so the plan only surfaces the specification. + /// + public PartitionSpec? Partition { get; init; } + + /// Gets a value indicating whether deliveries of this message type are partitioned for ordered processing. + public bool IsPartitioned => Partition is not null; +} diff --git a/src/Vulthil.Messaging/Transport/MessageExecutionRegistry.cs b/src/Vulthil.Messaging/Transport/MessageExecutionRegistry.cs new file mode 100644 index 0000000..93e2ea4 --- /dev/null +++ b/src/Vulthil.Messaging/Transport/MessageExecutionRegistry.cs @@ -0,0 +1,143 @@ +using Vulthil.Messaging.Queues; + +namespace Vulthil.Messaging.Transport; + +/// +/// Transport-agnostic registry that turns registrations into per-message-type +/// instances. It keys plans by wire URN (and CLR full name for the +/// bare-JSON receive path), fans a polymorphic registration out across every matching concrete subscription, +/// dedupes handlers, rejects a second request consumer for the same message type, and attaches the partition +/// specification read from . A transport drives it by +/// supplying an that builds its own delivery closures. +/// +/// The transport-specific handler type produced by the factory and stored in plans. +public sealed class MessageExecutionRegistry + where THandler : notnull +{ + private readonly IMessageConfigurationProvider _provider; + private readonly IMessageHandlerFactory _handlerFactory; + private readonly Dictionary> _plansByUrn = []; + private readonly Dictionary> _plansByFullName = new(StringComparer.Ordinal); + private readonly HashSet _urnsWithRequestConsumer = []; + + /// + /// Initializes a new instance of the class. + /// + /// The resolved messaging configuration, used for URN and partition lookups. + /// The transport factory that builds handlers for each registration. + public MessageExecutionRegistry(IMessageConfigurationProvider provider, IMessageHandlerFactory handlerFactory) + { + _provider = provider; + _handlerFactory = handlerFactory; + } + + /// + /// Gets the execution plans assembled so far, one per concrete message type, in no particular order. + /// + public IReadOnlyCollection> Plans => _plansByUrn.Values; + + /// + /// Assembles execution plans for every concrete message type consumed or subscribed by . + /// Idempotent across queues that share message types: plans are keyed globally by URN, so a type consumed by + /// several queues accumulates all their handlers in one plan. + /// + /// The queue whose registrations and subscriptions to register. + /// A second request consumer is registered for a message type that already has one. + public void RegisterQueue(QueueDefinition queue) + { + var effectiveSubscriptions = new HashSet(queue.Subscriptions.Select(s => s.MessageType)); + var concreteRegistrationTypes = queue.Registrations + .Select(r => r.MessageType) + .Where(m => m.Type is { IsAbstract: false, IsInterface: false }); + foreach (var concrete in concreteRegistrationTypes) + { + effectiveSubscriptions.Add(concrete); + } + + foreach (var subscription in effectiveSubscriptions) + { + var concreteType = subscription.Type; + var plan = GetOrAddPlan(subscription); + + foreach (var registration in queue.Registrations) + { + if (!registration.MessageType.Type.IsAssignableFrom(concreteType)) + { + continue; + } + + var entry = registration is RequestConsumerRegistration rpc + ? _handlerFactory.ForRequestConsumer(rpc.ConsumerType.Type, rpc.MessageType.Type, rpc.ResponseType, rpc.RetryPolicy) + : _handlerFactory.ForConsumer(registration.ConsumerType.Type, registration.MessageType.Type, registration.RetryPolicy); + + if (entry.Kind == HandlerKind.RequestConsumer && !_urnsWithRequestConsumer.Add(plan.Urn)) + { + throw new InvalidOperationException( + $"Queue '{queue.Name}' already has a request consumer registered for message type '{subscription.Name}'. " + + "A message type can have at most one request consumer per queue, since multiple responses would be ambiguous."); + } + + plan.Handlers.Add(entry.Handler); + } + } + } + + private MessageExecutionPlan GetOrAddPlan(MessageType messageType) + { + var urn = _provider.GetUrn(messageType.Type); + if (_plansByUrn.TryGetValue(urn, out var existing)) + { + return existing; + } + + var plan = new MessageExecutionPlan(messageType, urn) + { + Partition = _provider.GetPartition(messageType.Type), + }; + _plansByUrn[urn] = plan; + _plansByFullName[messageType.Name] = plan; + return plan; + } + + /// + /// Indicates whether any concrete message type subscribed or consumed by is + /// partitioned, read directly from . Because it does + /// not depend on built plans it is valid both during topology setup (which precedes ) + /// and afterwards. Drives ordered single dispatch and the single-active-consumer queue argument. + /// + /// The queue to inspect. + /// when any concrete message type on the queue is partitioned; otherwise . + public bool IsQueuePartitioned(QueueDefinition queue) + => queue.Subscriptions.Any(s => _provider.GetPartition(s.MessageType.Type) is not null) + || queue.Registrations.Any(r => + r.MessageType.Type is { IsAbstract: false, IsInterface: false } + && _provider.GetPartition(r.MessageType.Type) is not null); + + /// + /// Resolves a plan from the wire URN (envelope path). Returns when no plan matches. + /// + /// The wire URN as it appeared on the envelope. + /// The matching plan, or . + public MessageExecutionPlan? GetPlanByUrn(Uri urn) => _plansByUrn.GetValueOrDefault(urn); + + /// + /// Resolves a plan from the CLR full type name (bare-JSON compatibility path). Returns when no plan matches. + /// + /// The CLR full type name. + /// The matching plan, or . + public MessageExecutionPlan? GetPlanByFullName(string fullName) => _plansByFullName.GetValueOrDefault(fullName); + + /// + /// Convenience lookup used by the bare-JSON receive path and tests: tries URN parsing first, then falls back to CLR full name. + /// + /// A wire URN or CLR full type name. + /// The matching plan, or . + public MessageExecutionPlan? GetPlan(string key) + { + if (Uri.TryCreate(key, UriKind.Absolute, out var urn) && _plansByUrn.TryGetValue(urn, out var plan)) + { + return plan; + } + return _plansByFullName.GetValueOrDefault(key); + } +} From b028d039568e3475c7f87d5baa1aa6d76f4d0b33 Mon Sep 17 00:00:00 2001 From: Vulthil Date: Thu, 4 Jun 2026 21:06:51 +0200 Subject: [PATCH 25/42] refactor(messaging): make IPublishContext write-only via Set methods; SetTimeout returns void --- .../PublicAPI.Unshipped.txt | 17 +++----- .../Publishers/IPublishContext.cs | 42 +++++++++++-------- .../Publishers/IRequestContext.cs | 4 +- .../Consumers/MessageContext.cs | 13 +++++- .../Requests/PublishContext.cs | 15 ++++--- .../Requests/RequestContext.cs | 6 +-- 6 files changed, 54 insertions(+), 43 deletions(-) diff --git a/src/Vulthil.Messaging.Abstractions/PublicAPI.Unshipped.txt b/src/Vulthil.Messaging.Abstractions/PublicAPI.Unshipped.txt index d9f2d6e..a729e19 100644 --- a/src/Vulthil.Messaging.Abstractions/PublicAPI.Unshipped.txt +++ b/src/Vulthil.Messaging.Abstractions/PublicAPI.Unshipped.txt @@ -68,23 +68,18 @@ Vulthil.Messaging.Abstractions.Consumers.IRequestConsumer.C Vulthil.Messaging.Abstractions.Publishers.IPublishContext Vulthil.Messaging.Abstractions.Publishers.IPublishContext.AddHeader(string! key, object? value) -> void Vulthil.Messaging.Abstractions.Publishers.IPublishContext.AddHeaders(System.Collections.Generic.IDictionary! headers) -> void -Vulthil.Messaging.Abstractions.Publishers.IPublishContext.ConversationId.get -> string? -Vulthil.Messaging.Abstractions.Publishers.IPublishContext.ConversationId.set -> void -Vulthil.Messaging.Abstractions.Publishers.IPublishContext.FaultAddress.get -> System.Uri? -Vulthil.Messaging.Abstractions.Publishers.IPublishContext.FaultAddress.set -> void -Vulthil.Messaging.Abstractions.Publishers.IPublishContext.InitiatorId.get -> string? -Vulthil.Messaging.Abstractions.Publishers.IPublishContext.InitiatorId.set -> void -Vulthil.Messaging.Abstractions.Publishers.IPublishContext.MessageId.get -> string? -Vulthil.Messaging.Abstractions.Publishers.IPublishContext.MessageId.set -> void -Vulthil.Messaging.Abstractions.Publishers.IPublishContext.ResponseAddress.get -> System.Uri? -Vulthil.Messaging.Abstractions.Publishers.IPublishContext.ResponseAddress.set -> void +Vulthil.Messaging.Abstractions.Publishers.IPublishContext.SetConversationId(string! conversationId) -> void Vulthil.Messaging.Abstractions.Publishers.IPublishContext.SetCorrelationId(string! correlationId) -> void +Vulthil.Messaging.Abstractions.Publishers.IPublishContext.SetFaultAddress(System.Uri! faultAddress) -> void +Vulthil.Messaging.Abstractions.Publishers.IPublishContext.SetInitiatorId(string! initiatorId) -> void +Vulthil.Messaging.Abstractions.Publishers.IPublishContext.SetMessageId(string! messageId) -> void +Vulthil.Messaging.Abstractions.Publishers.IPublishContext.SetResponseAddress(System.Uri! responseAddress) -> void Vulthil.Messaging.Abstractions.Publishers.IPublishContext.SetRoutingKey(string! routingKey) -> void Vulthil.Messaging.Abstractions.Publishers.IPublisher Vulthil.Messaging.Abstractions.Publishers.IPublisher.PublishAsync(TMessage message, System.Func? configureContext = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! Vulthil.Messaging.Abstractions.Publishers.IPublisher.PublishAsync(TMessage message, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! Vulthil.Messaging.Abstractions.Publishers.IRequestContext -Vulthil.Messaging.Abstractions.Publishers.IRequestContext.SetTimeout(System.TimeSpan timeout) -> Vulthil.Messaging.Abstractions.Publishers.IRequestContext! +Vulthil.Messaging.Abstractions.Publishers.IRequestContext.SetTimeout(System.TimeSpan timeout) -> void Vulthil.Messaging.Abstractions.Publishers.IRequester Vulthil.Messaging.Abstractions.Publishers.IRequester.RequestAsync(TRequest message, System.Func? configureContext = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! Vulthil.Messaging.Abstractions.Publishers.IRequester.RequestAsync(TRequest message, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task!>! diff --git a/src/Vulthil.Messaging.Abstractions/Publishers/IPublishContext.cs b/src/Vulthil.Messaging.Abstractions/Publishers/IPublishContext.cs index 36ec7c0..9f5d3bb 100644 --- a/src/Vulthil.Messaging.Abstractions/Publishers/IPublishContext.cs +++ b/src/Vulthil.Messaging.Abstractions/Publishers/IPublishContext.cs @@ -1,7 +1,8 @@ namespace Vulthil.Messaging.Abstractions.Publishers; /// -/// Provides a mutable context for configuring outgoing message properties. +/// Provides a write-only context for configuring outgoing message properties. Every member sets a value on the +/// message being published; nothing is read back through this interface. /// public interface IPublishContext { @@ -16,34 +17,39 @@ public interface IPublishContext /// The correlation identifier. void SetCorrelationId(string correlationId); /// - /// Adds a custom header to the published message. + /// Sets the unique message identifier assigned to the outgoing message. /// - /// The header key. - /// The header value. - void AddHeader(string key, object? value); + /// The message identifier. + void SetMessageId(string messageId); /// - /// Adds multiple custom headers to the published message. + /// Sets the conversation identifier that groups related messages across services. /// - /// The headers to add. - void AddHeaders(IDictionary headers); + /// The conversation identifier. + void SetConversationId(string conversationId); /// - /// Gets or sets the unique message identifier assigned to the outgoing message. + /// Sets the identifier of the message that initiated this chain. /// - string? MessageId { get; set; } + /// The initiator identifier. + void SetInitiatorId(string initiatorId); /// - /// Gets or sets the conversation identifier that groups related messages across services. + /// Sets the address where replies to this message should be sent. /// - string? ConversationId { get; set; } + /// The response address. + void SetResponseAddress(Uri responseAddress); /// - /// Gets or sets the identifier of the message that initiated this chain. + /// Sets the address where fault notifications for this message should be sent. /// - string? InitiatorId { get; set; } + /// The fault address. + void SetFaultAddress(Uri faultAddress); /// - /// Gets or sets the address where replies to this message should be sent. + /// Adds a custom header to the published message. /// - Uri? ResponseAddress { get; set; } + /// The header key. + /// The header value. + void AddHeader(string key, object? value); /// - /// Gets or sets the address where fault notifications for this message should be sent. + /// Adds multiple custom headers to the published message. /// - Uri? FaultAddress { get; set; } + /// The headers to add. + void AddHeaders(IDictionary headers); } diff --git a/src/Vulthil.Messaging.Abstractions/Publishers/IRequestContext.cs b/src/Vulthil.Messaging.Abstractions/Publishers/IRequestContext.cs index cc263a8..aae7bbe 100644 --- a/src/Vulthil.Messaging.Abstractions/Publishers/IRequestContext.cs +++ b/src/Vulthil.Messaging.Abstractions/Publishers/IRequestContext.cs @@ -8,6 +8,6 @@ public interface IRequestContext : IPublishContext /// /// Sets the timeout for the request, after which it should be considered failed if no response is received. /// - IRequestContext SetTimeout(TimeSpan timeout); - + /// The maximum time to wait for a response. + void SetTimeout(TimeSpan timeout); } diff --git a/src/Vulthil.Messaging.RabbitMq/Consumers/MessageContext.cs b/src/Vulthil.Messaging.RabbitMq/Consumers/MessageContext.cs index 86cbb0b..2f879b4 100644 --- a/src/Vulthil.Messaging.RabbitMq/Consumers/MessageContext.cs +++ b/src/Vulthil.Messaging.RabbitMq/Consumers/MessageContext.cs @@ -78,8 +78,17 @@ private async ValueTask PropagateAndConfigureAsync(IPublishContext ctx, Func Headers { get; } = []; public string? RoutingKey { get; private set; } public string? CorrelationId { get; private set; } - public string? MessageId { get; set; } - public string? ConversationId { get => Headers.TryGetValue("ConversationId", out var value) && value is string conversationId ? conversationId : null; set => Headers["ConversationId"] = value; } - public string? InitiatorId { get => Headers.TryGetValue("InitiatorId", out var value) && value is string initiatorId ? initiatorId : null; set => Headers["InitiatorId"] = value; } + public string? MessageId { get; private set; } + public string? ConversationId { get => Headers.TryGetValue("ConversationId", out var value) && value is string conversationId ? conversationId : null; private set => Headers["ConversationId"] = value; } + public string? InitiatorId { get => Headers.TryGetValue("InitiatorId", out var value) && value is string initiatorId ? initiatorId : null; private set => Headers["InitiatorId"] = value; } public Uri? SourceAddress { get => Headers.TryGetValue("SourceAddress", out var value) && value is string sourceAddress ? new Uri(sourceAddress) : null; set => Headers["SourceAddress"] = MapUriToString(value); } public Uri? DestinationAddress { get => Headers.TryGetValue("DestinationAddress", out var value) && value is string destinationAddress ? new Uri(destinationAddress) : null; set => Headers["DestinationAddress"] = MapUriToString(value); } - public Uri? ResponseAddress { get => Headers.TryGetValue("ResponseAddress", out var value) && value is string responseAddress ? new Uri(responseAddress) : null; set => Headers["ResponseAddress"] = MapUriToString(value); } - public Uri? FaultAddress { get => Headers.TryGetValue("FaultAddress", out var value) && value is string faultAddress ? new Uri(faultAddress) : null; set => Headers["FaultAddress"] = MapUriToString(value); } + public Uri? ResponseAddress { get => Headers.TryGetValue("ResponseAddress", out var value) && value is string responseAddress ? new Uri(responseAddress) : null; private set => Headers["ResponseAddress"] = MapUriToString(value); } + public Uri? FaultAddress { get => Headers.TryGetValue("FaultAddress", out var value) && value is string faultAddress ? new Uri(faultAddress) : null; private set => Headers["FaultAddress"] = MapUriToString(value); } public void AddHeader(string key, object? value) => Headers[key] = value; public void AddHeaders(IDictionary headers) @@ -25,6 +25,11 @@ public void AddHeaders(IDictionary headers) } public void SetRoutingKey(string routingKey) => RoutingKey = routingKey; public void SetCorrelationId(string correlationId) => CorrelationId = correlationId; + public void SetMessageId(string messageId) => MessageId = messageId; + public void SetConversationId(string conversationId) => ConversationId = conversationId; + public void SetInitiatorId(string initiatorId) => InitiatorId = initiatorId; + public void SetResponseAddress(Uri responseAddress) => ResponseAddress = responseAddress; + public void SetFaultAddress(Uri faultAddress) => FaultAddress = faultAddress; private static string? MapUriToString(Uri? uri) { if (uri is null) diff --git a/src/Vulthil.Messaging.RabbitMq/Requests/RequestContext.cs b/src/Vulthil.Messaging.RabbitMq/Requests/RequestContext.cs index 05681fa..4195713 100644 --- a/src/Vulthil.Messaging.RabbitMq/Requests/RequestContext.cs +++ b/src/Vulthil.Messaging.RabbitMq/Requests/RequestContext.cs @@ -6,9 +6,5 @@ internal sealed class RequestContext : PublishContext, IRequestContext { internal TimeSpan? Timeout { get; private set; } - public IRequestContext SetTimeout(TimeSpan timeout) - { - Timeout = timeout; - return this; - } + public void SetTimeout(TimeSpan timeout) => Timeout = timeout; } From e9885329a03276d5f37ecbedb33bf1d574c8bccf Mon Sep 17 00:00:00 2001 From: Vulthil Date: Thu, 4 Jun 2026 21:23:49 +0200 Subject: [PATCH 26/42] refactor(messaging): make publish context and envelope factory public --- .../Publishing/RabbitMqPublisher.cs | 7 +- .../RabbitMqAddress.cs | 34 +++++++++ .../Requests/PublishContext.cs | 61 ---------------- .../Requests/RabbitMqRequester.cs | 6 +- .../Requests/RequestContext.cs | 10 --- .../Sending/RabbitMqSendEndpoint.cs | 7 +- .../Sending/RabbitMqSendEndpointProvider.cs | 3 +- src/Vulthil.Messaging/PublicAPI.Unshipped.txt | 29 ++++++++ .../Transport}/MessageEnvelopeFactory.cs | 20 ++++-- .../Transport/PublishContext.cs | 70 +++++++++++++++++++ .../Transport/RequestContext.cs | 17 +++++ .../MessageContextSendTests.cs | 2 +- 12 files changed, 177 insertions(+), 89 deletions(-) create mode 100644 src/Vulthil.Messaging.RabbitMq/RabbitMqAddress.cs delete mode 100644 src/Vulthil.Messaging.RabbitMq/Requests/PublishContext.cs delete mode 100644 src/Vulthil.Messaging.RabbitMq/Requests/RequestContext.cs rename src/{Vulthil.Messaging.RabbitMq/Envelope => Vulthil.Messaging/Transport}/MessageEnvelopeFactory.cs (61%) create mode 100644 src/Vulthil.Messaging/Transport/PublishContext.cs create mode 100644 src/Vulthil.Messaging/Transport/RequestContext.cs diff --git a/src/Vulthil.Messaging.RabbitMq/Publishing/RabbitMqPublisher.cs b/src/Vulthil.Messaging.RabbitMq/Publishing/RabbitMqPublisher.cs index 30fb5cf..d013cc8 100644 --- a/src/Vulthil.Messaging.RabbitMq/Publishing/RabbitMqPublisher.cs +++ b/src/Vulthil.Messaging.RabbitMq/Publishing/RabbitMqPublisher.cs @@ -4,10 +4,9 @@ using Microsoft.Extensions.Logging; using RabbitMQ.Client; using Vulthil.Messaging.Abstractions.Publishers; -using Vulthil.Messaging.RabbitMq.Envelope; using Vulthil.Messaging.RabbitMq.Logging; -using Vulthil.Messaging.RabbitMq.Requests; using Vulthil.Messaging.RabbitMq.Telemetry; +using Vulthil.Messaging.Transport; namespace Vulthil.Messaging.RabbitMq.Publishing; @@ -141,10 +140,10 @@ public async Task PublishAsync( { Type = urnString, MessageId = messageId, - ReplyTo = PublishContext.ResolveRoutingKeyFromUri(publishContext.ResponseAddress), + ReplyTo = RabbitMqAddress.ResolveRoutingKey(publishContext.ResponseAddress), CorrelationId = correlationId, ContentType = RabbitMqConstants.ContentType, - Headers = publishContext.Headers, + Headers = new Dictionary(publishContext.Headers), Persistent = true, Timestamp = new AmqpTimestamp(DateTimeOffset.UtcNow.ToUnixTimeSeconds()), }; diff --git a/src/Vulthil.Messaging.RabbitMq/RabbitMqAddress.cs b/src/Vulthil.Messaging.RabbitMq/RabbitMqAddress.cs new file mode 100644 index 0000000..673a533 --- /dev/null +++ b/src/Vulthil.Messaging.RabbitMq/RabbitMqAddress.cs @@ -0,0 +1,34 @@ +namespace Vulthil.Messaging.RabbitMq; + +/// +/// Resolves Vulthil and AMQP address URIs to the broker routing key (queue name) they denote. +/// +internal static class RabbitMqAddress +{ + /// + /// Maps an address URI to the routing key used to reach it: the queue name for queue: and + /// AMQP (rabbitmq:/amqp:/amqps:) URIs, or the full string for anything else. + /// Returns when is . + /// + /// The address URI to resolve, or . + /// The routing key, or when is . + public static string? ResolveRoutingKey(Uri? uri) + { + if (uri is null) + { + return null; + } + + if (uri.Scheme == "queue") + { + return uri.LocalPath.TrimStart('/'); + } + + if (uri.Scheme == "rabbitmq" || uri.Scheme == "amqp" || uri.Scheme == "amqps") + { + return uri.AbsolutePath.TrimStart('/'); + } + + return uri.ToString(); + } +} diff --git a/src/Vulthil.Messaging.RabbitMq/Requests/PublishContext.cs b/src/Vulthil.Messaging.RabbitMq/Requests/PublishContext.cs deleted file mode 100644 index b69a1ba..0000000 --- a/src/Vulthil.Messaging.RabbitMq/Requests/PublishContext.cs +++ /dev/null @@ -1,61 +0,0 @@ -using Vulthil.Messaging.Abstractions.Publishers; - -namespace Vulthil.Messaging.RabbitMq.Requests; - -internal class PublishContext : IPublishContext -{ - internal Dictionary Headers { get; } = []; - public string? RoutingKey { get; private set; } - public string? CorrelationId { get; private set; } - public string? MessageId { get; private set; } - public string? ConversationId { get => Headers.TryGetValue("ConversationId", out var value) && value is string conversationId ? conversationId : null; private set => Headers["ConversationId"] = value; } - public string? InitiatorId { get => Headers.TryGetValue("InitiatorId", out var value) && value is string initiatorId ? initiatorId : null; private set => Headers["InitiatorId"] = value; } - public Uri? SourceAddress { get => Headers.TryGetValue("SourceAddress", out var value) && value is string sourceAddress ? new Uri(sourceAddress) : null; set => Headers["SourceAddress"] = MapUriToString(value); } - public Uri? DestinationAddress { get => Headers.TryGetValue("DestinationAddress", out var value) && value is string destinationAddress ? new Uri(destinationAddress) : null; set => Headers["DestinationAddress"] = MapUriToString(value); } - public Uri? ResponseAddress { get => Headers.TryGetValue("ResponseAddress", out var value) && value is string responseAddress ? new Uri(responseAddress) : null; private set => Headers["ResponseAddress"] = MapUriToString(value); } - public Uri? FaultAddress { get => Headers.TryGetValue("FaultAddress", out var value) && value is string faultAddress ? new Uri(faultAddress) : null; private set => Headers["FaultAddress"] = MapUriToString(value); } - - public void AddHeader(string key, object? value) => Headers[key] = value; - public void AddHeaders(IDictionary headers) - { - foreach (var item in headers) - { - Headers[item.Key] = item.Value; - } - } - public void SetRoutingKey(string routingKey) => RoutingKey = routingKey; - public void SetCorrelationId(string correlationId) => CorrelationId = correlationId; - public void SetMessageId(string messageId) => MessageId = messageId; - public void SetConversationId(string conversationId) => ConversationId = conversationId; - public void SetInitiatorId(string initiatorId) => InitiatorId = initiatorId; - public void SetResponseAddress(Uri responseAddress) => ResponseAddress = responseAddress; - public void SetFaultAddress(Uri faultAddress) => FaultAddress = faultAddress; - private static string? MapUriToString(Uri? uri) - { - if (uri is null) - { - return null; - } - - return uri.Scheme == "queue" ? uri.LocalPath.TrimStart('/') : uri.ToString(); - } - public static string? ResolveRoutingKeyFromUri(Uri? uri) - { - if (uri == null) - { - return null; - } - - if (uri.Scheme == "queue") - { - return uri.LocalPath.TrimStart('/'); - } - - if (uri.Scheme == "rabbitmq" || uri.Scheme == "amqp" || uri.Scheme == "amqps") - { - return uri.AbsolutePath.TrimStart('/'); - } - - return uri.ToString(); - } -} diff --git a/src/Vulthil.Messaging.RabbitMq/Requests/RabbitMqRequester.cs b/src/Vulthil.Messaging.RabbitMq/Requests/RabbitMqRequester.cs index 65d036d..67dcbae 100644 --- a/src/Vulthil.Messaging.RabbitMq/Requests/RabbitMqRequester.cs +++ b/src/Vulthil.Messaging.RabbitMq/Requests/RabbitMqRequester.cs @@ -4,10 +4,10 @@ using Microsoft.Extensions.Logging; using RabbitMQ.Client; using Vulthil.Messaging.Abstractions.Publishers; -using Vulthil.Messaging.RabbitMq.Envelope; using Vulthil.Messaging.RabbitMq.Logging; using Vulthil.Messaging.RabbitMq.Publishing; using Vulthil.Messaging.RabbitMq.Telemetry; +using Vulthil.Messaging.Transport; using Vulthil.Results; namespace Vulthil.Messaging.RabbitMq.Requests; @@ -80,7 +80,7 @@ public async Task> RequestAsync( var urnString = urn.AbsoluteUri; var replyQueue = await _listener.GetReplyToQueueNameAsync(cancellationToken); - var replyTo = PublishContext.ResolveRoutingKeyFromUri(requestContext.ResponseAddress) ?? replyQueue; + var replyTo = RabbitMqAddress.ResolveRoutingKey(requestContext.ResponseAddress) ?? replyQueue; using var activity = MessagingInstrumentation.ActivitySource.StartActivity( $"{exchange} request", @@ -110,7 +110,7 @@ public async Task> RequestAsync( Type = urnString, Timestamp = new AmqpTimestamp(DateTimeOffset.UtcNow.ToUnixTimeSeconds()), Expiration = timeout.TotalMilliseconds.ToString("F0", CultureInfo.InvariantCulture), - Headers = requestContext.Headers, + Headers = new Dictionary(requestContext.Headers), MessageId = messageId, }; diff --git a/src/Vulthil.Messaging.RabbitMq/Requests/RequestContext.cs b/src/Vulthil.Messaging.RabbitMq/Requests/RequestContext.cs deleted file mode 100644 index 4195713..0000000 --- a/src/Vulthil.Messaging.RabbitMq/Requests/RequestContext.cs +++ /dev/null @@ -1,10 +0,0 @@ -using Vulthil.Messaging.Abstractions.Publishers; - -namespace Vulthil.Messaging.RabbitMq.Requests; - -internal sealed class RequestContext : PublishContext, IRequestContext -{ - internal TimeSpan? Timeout { get; private set; } - - public void SetTimeout(TimeSpan timeout) => Timeout = timeout; -} diff --git a/src/Vulthil.Messaging.RabbitMq/Sending/RabbitMqSendEndpoint.cs b/src/Vulthil.Messaging.RabbitMq/Sending/RabbitMqSendEndpoint.cs index b587593..373d06a 100644 --- a/src/Vulthil.Messaging.RabbitMq/Sending/RabbitMqSendEndpoint.cs +++ b/src/Vulthil.Messaging.RabbitMq/Sending/RabbitMqSendEndpoint.cs @@ -3,11 +3,10 @@ using Microsoft.Extensions.Logging; using RabbitMQ.Client; using Vulthil.Messaging.Abstractions.Publishers; -using Vulthil.Messaging.RabbitMq.Envelope; using Vulthil.Messaging.RabbitMq.Logging; using Vulthil.Messaging.RabbitMq.Publishing; -using Vulthil.Messaging.RabbitMq.Requests; using Vulthil.Messaging.RabbitMq.Telemetry; +using Vulthil.Messaging.Transport; namespace Vulthil.Messaging.RabbitMq.Sending; @@ -81,10 +80,10 @@ public async Task SendAsync( { Type = urnString, MessageId = messageId, - ReplyTo = PublishContext.ResolveRoutingKeyFromUri(publishContext.ResponseAddress), + ReplyTo = RabbitMqAddress.ResolveRoutingKey(publishContext.ResponseAddress), CorrelationId = correlationId, ContentType = RabbitMqConstants.ContentType, - Headers = publishContext.Headers, + Headers = new Dictionary(publishContext.Headers), Persistent = true, Timestamp = new AmqpTimestamp(DateTimeOffset.UtcNow.ToUnixTimeSeconds()), }; diff --git a/src/Vulthil.Messaging.RabbitMq/Sending/RabbitMqSendEndpointProvider.cs b/src/Vulthil.Messaging.RabbitMq/Sending/RabbitMqSendEndpointProvider.cs index 93e86f8..bf61d10 100644 --- a/src/Vulthil.Messaging.RabbitMq/Sending/RabbitMqSendEndpointProvider.cs +++ b/src/Vulthil.Messaging.RabbitMq/Sending/RabbitMqSendEndpointProvider.cs @@ -2,7 +2,6 @@ using Microsoft.Extensions.Logging; using Vulthil.Messaging.Abstractions.Publishers; using Vulthil.Messaging.RabbitMq.Publishing; -using Vulthil.Messaging.RabbitMq.Requests; namespace Vulthil.Messaging.RabbitMq.Sending; @@ -33,7 +32,7 @@ public ValueTask GetSendEndpointAsync(Uri address, CancellationTo private ISendEndpoint CreateEndpoint(Uri address) { - var queueName = PublishContext.ResolveRoutingKeyFromUri(address); + var queueName = RabbitMqAddress.ResolveRoutingKey(address); if (string.IsNullOrEmpty(queueName)) { throw new ArgumentException( diff --git a/src/Vulthil.Messaging/PublicAPI.Unshipped.txt b/src/Vulthil.Messaging/PublicAPI.Unshipped.txt index a20ffd0..6501a2c 100644 --- a/src/Vulthil.Messaging/PublicAPI.Unshipped.txt +++ b/src/Vulthil.Messaging/PublicAPI.Unshipped.txt @@ -221,6 +221,8 @@ Vulthil.Messaging.Transport.MessageEnvelope.SentTime.get -> System.DateTimeOffse Vulthil.Messaging.Transport.MessageEnvelope.SentTime.init -> void Vulthil.Messaging.Transport.MessageEnvelope.SourceAddress.get -> string? Vulthil.Messaging.Transport.MessageEnvelope.SourceAddress.init -> void +Vulthil.Messaging.Transport.MessageEnvelopeFactory +static Vulthil.Messaging.Transport.MessageEnvelopeFactory.Create(TMessage message, Vulthil.Messaging.Transport.PublishContext! publishContext, string! messageId, string! correlationId, System.Uri! urn, System.Text.Json.JsonSerializerOptions! jsonOptions, string? requestId = null) -> Vulthil.Messaging.Transport.MessageEnvelope! Vulthil.Messaging.Transport.MessageExecutionPlan Vulthil.Messaging.Transport.MessageExecutionPlan.Handlers.get -> System.Collections.ObjectModel.Collection! Vulthil.Messaging.Transport.MessageExecutionPlan.IsPartitioned.get -> bool @@ -239,6 +241,33 @@ Vulthil.Messaging.Transport.MessageExecutionRegistry.IsQueuePartitione Vulthil.Messaging.Transport.MessageExecutionRegistry.MessageExecutionRegistry(Vulthil.Messaging.IMessageConfigurationProvider! provider, Vulthil.Messaging.Transport.IMessageHandlerFactory! handlerFactory) -> void Vulthil.Messaging.Transport.MessageExecutionRegistry.Plans.get -> System.Collections.Generic.IReadOnlyCollection!>! Vulthil.Messaging.Transport.MessageExecutionRegistry.RegisterQueue(Vulthil.Messaging.Queues.QueueDefinition! queue) -> void +Vulthil.Messaging.Transport.PublishContext +Vulthil.Messaging.Transport.PublishContext.AddHeader(string! key, object? value) -> void +Vulthil.Messaging.Transport.PublishContext.AddHeaders(System.Collections.Generic.IDictionary! headers) -> void +Vulthil.Messaging.Transport.PublishContext.ConversationId.get -> string? +Vulthil.Messaging.Transport.PublishContext.CorrelationId.get -> string? +Vulthil.Messaging.Transport.PublishContext.DestinationAddress.get -> System.Uri? +Vulthil.Messaging.Transport.PublishContext.DestinationAddress.set -> void +Vulthil.Messaging.Transport.PublishContext.FaultAddress.get -> System.Uri? +Vulthil.Messaging.Transport.PublishContext.Headers.get -> System.Collections.Generic.IReadOnlyDictionary! +Vulthil.Messaging.Transport.PublishContext.InitiatorId.get -> string? +Vulthil.Messaging.Transport.PublishContext.MessageId.get -> string? +Vulthil.Messaging.Transport.PublishContext.PublishContext() -> void +Vulthil.Messaging.Transport.PublishContext.ResponseAddress.get -> System.Uri? +Vulthil.Messaging.Transport.PublishContext.RoutingKey.get -> string? +Vulthil.Messaging.Transport.PublishContext.SetConversationId(string! conversationId) -> void +Vulthil.Messaging.Transport.PublishContext.SetCorrelationId(string! correlationId) -> void +Vulthil.Messaging.Transport.PublishContext.SetFaultAddress(System.Uri! faultAddress) -> void +Vulthil.Messaging.Transport.PublishContext.SetInitiatorId(string! initiatorId) -> void +Vulthil.Messaging.Transport.PublishContext.SetMessageId(string! messageId) -> void +Vulthil.Messaging.Transport.PublishContext.SetResponseAddress(System.Uri! responseAddress) -> void +Vulthil.Messaging.Transport.PublishContext.SetRoutingKey(string! routingKey) -> void +Vulthil.Messaging.Transport.PublishContext.SourceAddress.get -> System.Uri? +Vulthil.Messaging.Transport.PublishContext.SourceAddress.set -> void +Vulthil.Messaging.Transport.RequestContext +Vulthil.Messaging.Transport.RequestContext.RequestContext() -> void +Vulthil.Messaging.Transport.RequestContext.SetTimeout(System.TimeSpan timeout) -> void +Vulthil.Messaging.Transport.RequestContext.Timeout.get -> System.TimeSpan? Vulthil.Messaging.Transport.RpcFault const Vulthil.Messaging.Transport.RpcFault.Urn = "urn:message:Vulthil:RpcFault" -> string! Vulthil.Messaging.Transport.RpcFault.ExceptionType.get -> string! diff --git a/src/Vulthil.Messaging.RabbitMq/Envelope/MessageEnvelopeFactory.cs b/src/Vulthil.Messaging/Transport/MessageEnvelopeFactory.cs similarity index 61% rename from src/Vulthil.Messaging.RabbitMq/Envelope/MessageEnvelopeFactory.cs rename to src/Vulthil.Messaging/Transport/MessageEnvelopeFactory.cs index 5277fb5..cbc94ee 100644 --- a/src/Vulthil.Messaging.RabbitMq/Envelope/MessageEnvelopeFactory.cs +++ b/src/Vulthil.Messaging/Transport/MessageEnvelopeFactory.cs @@ -1,10 +1,13 @@ using System.Text.Json; -using Vulthil.Messaging.RabbitMq.Requests; -using Vulthil.Messaging.Transport; -namespace Vulthil.Messaging.RabbitMq.Envelope; +namespace Vulthil.Messaging.Transport; -internal static class MessageEnvelopeFactory +/// +/// Builds instances from the resolved publish state for a single outgoing message. +/// Promotes the reserved metadata headers carried by a to typed envelope fields and +/// copies the remaining custom headers verbatim. +/// +public static class MessageEnvelopeFactory { private static readonly HashSet PromotedHeaderKeys = new(StringComparer.Ordinal) { @@ -19,6 +22,15 @@ internal static class MessageEnvelopeFactory /// /// Builds a from the resolved publish state for a single outgoing message. /// + /// The message type being published. + /// The message payload to serialize into the envelope. + /// The resolved publish configuration (addresses, headers, conversation metadata). + /// The unique identifier assigned to the message. + /// The business correlation identifier. + /// The stable wire URN identifying the message type. + /// The serializer options used to serialize the payload. + /// For request/reply, the request identifier the reply echoes; otherwise . + /// The constructed envelope. public static MessageEnvelope Create( TMessage message, PublishContext publishContext, diff --git a/src/Vulthil.Messaging/Transport/PublishContext.cs b/src/Vulthil.Messaging/Transport/PublishContext.cs new file mode 100644 index 0000000..6244296 --- /dev/null +++ b/src/Vulthil.Messaging/Transport/PublishContext.cs @@ -0,0 +1,70 @@ +using Vulthil.Messaging.Abstractions.Publishers; + +namespace Vulthil.Messaging.Transport; + +/// +/// Mutable, write-through configuration captured for a single outgoing message. A transport creates one, +/// passes it to the caller's configure callback as , then reads the resolved +/// values to build its wire message. Metadata that maps to typed envelope fields is stored under reserved header +/// keys and surfaced through the strongly-typed properties. +/// +public class PublishContext : IPublishContext +{ + private readonly Dictionary _headers = []; + + /// Gets the custom and reserved headers accumulated for the message. + public IReadOnlyDictionary Headers => _headers; + /// Gets the routing key, or if none was set. + public string? RoutingKey { get; private set; } + /// Gets the correlation identifier, or if none was set. + public string? CorrelationId { get; private set; } + /// Gets the message identifier, or if none was set. + public string? MessageId { get; private set; } + /// Gets the conversation identifier, or if none was set. + public string? ConversationId { get => _headers.TryGetValue("ConversationId", out var value) && value is string conversationId ? conversationId : null; private set => _headers["ConversationId"] = value; } + /// Gets the identifier of the message that initiated this chain, or if none was set. + public string? InitiatorId { get => _headers.TryGetValue("InitiatorId", out var value) && value is string initiatorId ? initiatorId : null; private set => _headers["InitiatorId"] = value; } + /// Gets or sets the address of the endpoint that produced the message; stamped by the transport. + public Uri? SourceAddress { get => _headers.TryGetValue("SourceAddress", out var value) && value is string sourceAddress ? new Uri(sourceAddress) : null; set => _headers["SourceAddress"] = MapUriToString(value); } + /// Gets or sets the address of the endpoint the message is sent to; stamped by the transport. + public Uri? DestinationAddress { get => _headers.TryGetValue("DestinationAddress", out var value) && value is string destinationAddress ? new Uri(destinationAddress) : null; set => _headers["DestinationAddress"] = MapUriToString(value); } + /// Gets the address where replies should be sent, or if none was set. + public Uri? ResponseAddress { get => _headers.TryGetValue("ResponseAddress", out var value) && value is string responseAddress ? new Uri(responseAddress) : null; private set => _headers["ResponseAddress"] = MapUriToString(value); } + /// Gets the address where fault notifications should be sent, or if none was set. + public Uri? FaultAddress { get => _headers.TryGetValue("FaultAddress", out var value) && value is string faultAddress ? new Uri(faultAddress) : null; private set => _headers["FaultAddress"] = MapUriToString(value); } + + /// + public void AddHeader(string key, object? value) => _headers[key] = value; + /// + public void AddHeaders(IDictionary headers) + { + foreach (var item in headers) + { + _headers[item.Key] = item.Value; + } + } + /// + public void SetRoutingKey(string routingKey) => RoutingKey = routingKey; + /// + public void SetCorrelationId(string correlationId) => CorrelationId = correlationId; + /// + public void SetMessageId(string messageId) => MessageId = messageId; + /// + public void SetConversationId(string conversationId) => ConversationId = conversationId; + /// + public void SetInitiatorId(string initiatorId) => InitiatorId = initiatorId; + /// + public void SetResponseAddress(Uri responseAddress) => ResponseAddress = responseAddress; + /// + public void SetFaultAddress(Uri faultAddress) => FaultAddress = faultAddress; + + private static string? MapUriToString(Uri? uri) + { + if (uri is null) + { + return null; + } + + return uri.Scheme == "queue" ? uri.LocalPath.TrimStart('/') : uri.ToString(); + } +} diff --git a/src/Vulthil.Messaging/Transport/RequestContext.cs b/src/Vulthil.Messaging/Transport/RequestContext.cs new file mode 100644 index 0000000..0db9811 --- /dev/null +++ b/src/Vulthil.Messaging/Transport/RequestContext.cs @@ -0,0 +1,17 @@ +using Vulthil.Messaging.Abstractions.Publishers; + +namespace Vulthil.Messaging.Transport; + +/// +/// A for request/reply: adds the per-request timeout. A transport creates one, +/// passes it to the caller's configure callback as , then reads +/// to bound the wait for a response. +/// +public sealed class RequestContext : PublishContext, IRequestContext +{ + /// Gets the per-request timeout, or to use the transport default. + public TimeSpan? Timeout { get; private set; } + + /// + public void SetTimeout(TimeSpan timeout) => Timeout = timeout; +} diff --git a/tests/Vulthil.Messaging.RabbitMq.Tests/MessageContextSendTests.cs b/tests/Vulthil.Messaging.RabbitMq.Tests/MessageContextSendTests.cs index fa3015a..36ebf56 100644 --- a/tests/Vulthil.Messaging.RabbitMq.Tests/MessageContextSendTests.cs +++ b/tests/Vulthil.Messaging.RabbitMq.Tests/MessageContextSendTests.cs @@ -2,7 +2,7 @@ using RabbitMQ.Client.Events; using Vulthil.Messaging.Abstractions.Publishers; using Vulthil.Messaging.RabbitMq.Consumers; -using Vulthil.Messaging.RabbitMq.Requests; +using Vulthil.Messaging.Transport; using Vulthil.xUnit; namespace Vulthil.Messaging.RabbitMq.Tests; From 3a7f484c3f553cc9b0680c4dc8e6cb64f90119e4 Mon Sep 17 00:00:00 2001 From: Vulthil Date: Thu, 4 Jun 2026 21:37:36 +0200 Subject: [PATCH 27/42] refactor(messaging): add public envelope-based message context builder --- .../Consumers/MessageContext.cs | 276 ------------------ .../Consumers/MessageContextFactory.cs | 140 +++++++++ .../Consumers/MessageHandler.cs | 2 +- .../Consumers/MessageHandlerFactory.cs | 8 +- .../Consumers/PartitionKeyExtractorFactory.cs | 4 +- .../Consumers/RabbitMqConsumerWorker.cs | 2 +- src/Vulthil.Messaging/PublicAPI.Unshipped.txt | 43 +++ .../Transport/MessageContext.cs | 181 ++++++++++++ .../MessageContextSendTests.cs | 4 +- .../MessageContextTests.cs | 10 +- .../RabbitMqConsumerWorkerTests.cs | 2 +- 11 files changed, 380 insertions(+), 292 deletions(-) delete mode 100644 src/Vulthil.Messaging.RabbitMq/Consumers/MessageContext.cs create mode 100644 src/Vulthil.Messaging.RabbitMq/Consumers/MessageContextFactory.cs create mode 100644 src/Vulthil.Messaging/Transport/MessageContext.cs diff --git a/src/Vulthil.Messaging.RabbitMq/Consumers/MessageContext.cs b/src/Vulthil.Messaging.RabbitMq/Consumers/MessageContext.cs deleted file mode 100644 index 2f879b4..0000000 --- a/src/Vulthil.Messaging.RabbitMq/Consumers/MessageContext.cs +++ /dev/null @@ -1,276 +0,0 @@ -using RabbitMQ.Client.Events; -using Vulthil.Messaging.Abstractions.Consumers; -using Vulthil.Messaging.Abstractions.Publishers; -using Vulthil.Messaging.Transport; - -namespace Vulthil.Messaging.RabbitMq.Consumers; - -internal record MessageContext : IMessageContext -{ - /// The publisher backing , or for a snapshot context not bound to a live transport. - public IPublisher? Publisher { get; init; } - - /// The send endpoint provider backing , or for a snapshot context not bound to a live transport. - public ISendEndpointProvider? SendEndpointProvider { get; init; } - - /// - public CancellationToken CancellationToken { get; init; } - - /// - public string? MessageId { get; init; } - /// - public required string CorrelationId { get; init; } - /// - public string? RequestId { get; init; } - /// - public required string RoutingKey { get; init; } - /// - public required IReadOnlyDictionary Headers { get; init; } - /// - public int RetryCount { get; init; } - /// - public bool Redelivered { get; init; } - /// - public string? ConversationId { get; init; } - /// - public string? InitiatorId { get; init; } - /// - public Uri? SourceAddress { get; init; } - /// - public Uri? DestinationAddress { get; init; } - /// - public Uri? ResponseAddress { get; init; } - /// - public Uri? FaultAddress { get; init; } - /// - public DateTimeOffset? SentTime { get; init; } - /// - public DateTimeOffset? ExpirationTime { get; init; } - - /// - public Task PublishAsync(TMessage message, Func? configure = null) - where TMessage : notnull - => (Publisher ?? throw SnapshotContextError()).PublishAsync( - message, - ctx => PropagateAndConfigureAsync(ctx, configure), - CancellationToken); - - /// - public async Task SendAsync( - Uri destinationAddress, - TMessage message, - Func? configure = null) - where TMessage : notnull - { - ArgumentNullException.ThrowIfNull(destinationAddress); - var provider = SendEndpointProvider ?? throw SnapshotContextError(); - var endpoint = await provider.GetSendEndpointAsync(destinationAddress, CancellationToken); - await endpoint.SendAsync( - message, - ctx => PropagateAndConfigureAsync(ctx, configure), - CancellationToken); - } - - private async ValueTask PropagateAndConfigureAsync(IPublishContext ctx, Func? configure) - { - // 1. Auto-propagate correlation metadata from the incoming context first. - if (!string.IsNullOrEmpty(CorrelationId)) - { - ctx.SetCorrelationId(CorrelationId); - } - - var conversationId = ConversationId ?? (string.IsNullOrEmpty(CorrelationId) ? null : CorrelationId); - if (!string.IsNullOrEmpty(conversationId)) - { - ctx.SetConversationId(conversationId); - } - - if (!string.IsNullOrEmpty(MessageId)) - { - ctx.SetInitiatorId(MessageId); - } - - // 2. Caller's configure callback runs last so it can override any auto-set value. - if (configure is not null) - { - await configure(ctx); - } - } - - private static InvalidOperationException SnapshotContextError() => - new("This message context is a snapshot (e.g. a fault envelope) and is not bound to a live transport."); - - /// - /// Creates a snapshot typed with no live transport binding. - /// - public static MessageContext CreateContext(TMessage message, BasicDeliverEventArgs ea) => - BuildTypedMetadata(message, ea, publisher: null, sendEndpointProvider: null, cancellationToken: default); - - /// - /// Creates a live typed bound to the specified transport services and cancellation token. - /// Used by the bare-JSON receive path; metadata comes from and its BasicProperties headers. - /// - public static MessageContext CreateContext( - TMessage message, - BasicDeliverEventArgs ea, - IPublisher? publisher, - ISendEndpointProvider? sendEndpointProvider, - CancellationToken cancellationToken) => - BuildTypedMetadata(message, ea, publisher, sendEndpointProvider, cancellationToken); - - /// - /// Creates a live typed from the envelope-bearing receive path. - /// Metadata comes from the envelope; transport-level fields (RoutingKey, Redelivered, retry count) still come from . - /// - public static MessageContext CreateContext( - TMessage message, - BasicDeliverEventArgs ea, - MessageEnvelope envelope, - IPublisher? publisher, - ISendEndpointProvider? sendEndpointProvider, - CancellationToken cancellationToken) => - BuildTypedMetadataFromEnvelope(message, ea, envelope, publisher, sendEndpointProvider, cancellationToken); - - /// - /// Builds a serializable of the delivery's transport metadata, - /// used to capture the original context when producing a fault. - /// - public static MessageContextSnapshot CreateSnapshot(BasicDeliverEventArgs ea) - { - var context = BuildMetadata(ea, publisher: null, sendEndpointProvider: null, cancellationToken: default); - return new MessageContextSnapshot - { - MessageId = context.MessageId, - RequestId = context.RequestId, - CorrelationId = context.CorrelationId, - ConversationId = context.ConversationId, - InitiatorId = context.InitiatorId, - SourceAddress = context.SourceAddress, - DestinationAddress = context.DestinationAddress, - ResponseAddress = context.ResponseAddress, - FaultAddress = context.FaultAddress, - RoutingKey = context.RoutingKey, - RetryCount = context.RetryCount, - }; - } - - private static MessageContext BuildMetadata( - BasicDeliverEventArgs ea, - IPublisher? publisher, - ISendEndpointProvider? sendEndpointProvider, - CancellationToken cancellationToken) - { - var props = ea.BasicProperties; - var headers = props.Headers ?? new Dictionary(); - return new MessageContext - { - Publisher = publisher, - SendEndpointProvider = sendEndpointProvider, - CancellationToken = cancellationToken, - MessageId = props.MessageId, - CorrelationId = props.CorrelationId ?? string.Empty, - RequestId = props.CorrelationId, - RoutingKey = ea.RoutingKey, - Headers = headers.ToDictionary(), - Redelivered = ea.Redelivered, - RetryCount = RabbitMqConstants.GetRetryCount(headers), - ConversationId = RabbitMqConstants.GetHeaderString(headers, "ConversationId"), - InitiatorId = RabbitMqConstants.GetHeaderString(headers, "InitiatorId"), - SourceAddress = RabbitMqConstants.GetHeaderUri(headers, "SourceAddress"), - DestinationAddress = RabbitMqConstants.GetHeaderUri(headers, "DestinationAddress"), - ResponseAddress = RabbitMqConstants.GetHeaderUri(headers, "ResponseAddress") - ?? (string.IsNullOrEmpty(props.ReplyTo) ? null : new Uri($"queue:{props.ReplyTo}")), - FaultAddress = RabbitMqConstants.GetHeaderUri(headers, "FaultAddress"), - SentTime = props.Timestamp.UnixTime > 0 ? DateTimeOffset.FromUnixTimeSeconds(props.Timestamp.UnixTime) : null, - ExpirationTime = RabbitMqConstants.TryParseExpiration(props.Expiration) - }; - } - - private static MessageContext BuildTypedMetadata( - TMessage message, - BasicDeliverEventArgs ea, - IPublisher? publisher, - ISendEndpointProvider? sendEndpointProvider, - CancellationToken cancellationToken) - { - var props = ea.BasicProperties; - var headers = props.Headers ?? new Dictionary(); - return new MessageContext - { - Message = message, - Publisher = publisher, - SendEndpointProvider = sendEndpointProvider, - CancellationToken = cancellationToken, - MessageId = props.MessageId, - CorrelationId = props.CorrelationId ?? string.Empty, - RequestId = props.CorrelationId, - RoutingKey = ea.RoutingKey, - Headers = headers.ToDictionary(), - Redelivered = ea.Redelivered, - RetryCount = RabbitMqConstants.GetRetryCount(headers), - ConversationId = RabbitMqConstants.GetHeaderString(headers, "ConversationId"), - InitiatorId = RabbitMqConstants.GetHeaderString(headers, "InitiatorId"), - SourceAddress = RabbitMqConstants.GetHeaderUri(headers, "SourceAddress"), - DestinationAddress = RabbitMqConstants.GetHeaderUri(headers, "DestinationAddress"), - ResponseAddress = RabbitMqConstants.GetHeaderUri(headers, "ResponseAddress") - ?? (string.IsNullOrEmpty(props.ReplyTo) ? null : new Uri($"queue:{props.ReplyTo}")), - FaultAddress = RabbitMqConstants.GetHeaderUri(headers, "FaultAddress"), - SentTime = props.Timestamp.UnixTime > 0 ? DateTimeOffset.FromUnixTimeSeconds(props.Timestamp.UnixTime) : null, - ExpirationTime = RabbitMqConstants.TryParseExpiration(props.Expiration) - }; - } - - private static MessageContext BuildTypedMetadataFromEnvelope( - TMessage message, - BasicDeliverEventArgs ea, - MessageEnvelope envelope, - IPublisher? publisher, - ISendEndpointProvider? sendEndpointProvider, - CancellationToken cancellationToken) - { - var props = ea.BasicProperties; - var transportHeaders = props.Headers ?? new Dictionary(); - var userHeaders = envelope.Headers is { } h ? new Dictionary(h) : []; - - return new MessageContext - { - Message = message, - Publisher = publisher, - SendEndpointProvider = sendEndpointProvider, - CancellationToken = cancellationToken, - MessageId = envelope.MessageId, - CorrelationId = envelope.CorrelationId ?? string.Empty, - RequestId = envelope.RequestId ?? envelope.CorrelationId, - RoutingKey = ea.RoutingKey, - Headers = userHeaders, - Redelivered = ea.Redelivered, - RetryCount = RabbitMqConstants.GetRetryCount(transportHeaders), - ConversationId = envelope.ConversationId, - InitiatorId = envelope.InitiatorId, - SourceAddress = ParseAddress(envelope.SourceAddress), - DestinationAddress = ParseAddress(envelope.DestinationAddress), - ResponseAddress = ParseAddress(envelope.ResponseAddress) - ?? (string.IsNullOrEmpty(props.ReplyTo) ? null : new Uri($"queue:{props.ReplyTo}")), - FaultAddress = ParseAddress(envelope.FaultAddress), - SentTime = envelope.SentTime, - ExpirationTime = envelope.ExpirationTime, - }; - } - - private static Uri? ParseAddress(string? value) - { - if (string.IsNullOrWhiteSpace(value)) - { - return null; - } - return Uri.TryCreate(value, UriKind.Absolute, out var uri) - ? uri - : new Uri($"queue:{value}"); - } -} - -internal sealed record MessageContext : MessageContext, IMessageContext -{ - /// - public required TMessage Message { get; init; } -} diff --git a/src/Vulthil.Messaging.RabbitMq/Consumers/MessageContextFactory.cs b/src/Vulthil.Messaging.RabbitMq/Consumers/MessageContextFactory.cs new file mode 100644 index 0000000..1829ff2 --- /dev/null +++ b/src/Vulthil.Messaging.RabbitMq/Consumers/MessageContextFactory.cs @@ -0,0 +1,140 @@ +using RabbitMQ.Client.Events; +using Vulthil.Messaging.Abstractions.Consumers; +using Vulthil.Messaging.Abstractions.Publishers; +using Vulthil.Messaging.Transport; + +namespace Vulthil.Messaging.RabbitMq.Consumers; + +/// +/// Builds instances from RabbitMQ deliveries: the AMQP property/header paths plus a +/// serializable snapshot for faults. The envelope receive path delegates the transport-agnostic mapping to +/// . +/// +internal static class MessageContextFactory +{ + /// + /// Creates a snapshot typed with no live transport binding. + /// + public static MessageContext CreateContext(TMessage message, BasicDeliverEventArgs ea) => + BuildTypedMetadata(message, ea, publisher: null, sendEndpointProvider: null, cancellationToken: default); + + /// + /// Creates a live typed bound to the specified transport services and cancellation token. + /// Used by the bare-JSON receive path; metadata comes from and its BasicProperties headers. + /// + public static MessageContext CreateContext( + TMessage message, + BasicDeliverEventArgs ea, + IPublisher? publisher, + ISendEndpointProvider? sendEndpointProvider, + CancellationToken cancellationToken) => + BuildTypedMetadata(message, ea, publisher, sendEndpointProvider, cancellationToken); + + /// + /// Creates a live typed from the envelope-bearing receive path. + /// Metadata comes from the envelope; transport-level fields (routing key, redelivery, retry count, reply-to fallback) + /// come from . + /// + public static MessageContext CreateContext( + TMessage message, + BasicDeliverEventArgs ea, + MessageEnvelope envelope, + IPublisher? publisher, + ISendEndpointProvider? sendEndpointProvider, + CancellationToken cancellationToken) + { + var transportHeaders = ea.BasicProperties.Headers ?? new Dictionary(); + return MessageContext.CreateFromEnvelope( + message, + envelope, + ea.RoutingKey, + ea.Redelivered, + RabbitMqConstants.GetRetryCount(transportHeaders), + ea.BasicProperties.ReplyTo, + publisher, + sendEndpointProvider, + cancellationToken); + } + + /// + /// Builds a serializable of the delivery's transport metadata, + /// used to capture the original context when producing a fault. + /// + public static MessageContextSnapshot CreateSnapshot(BasicDeliverEventArgs ea) + { + var context = BuildMetadata(ea); + return new MessageContextSnapshot + { + MessageId = context.MessageId, + RequestId = context.RequestId, + CorrelationId = context.CorrelationId, + ConversationId = context.ConversationId, + InitiatorId = context.InitiatorId, + SourceAddress = context.SourceAddress, + DestinationAddress = context.DestinationAddress, + ResponseAddress = context.ResponseAddress, + FaultAddress = context.FaultAddress, + RoutingKey = context.RoutingKey, + RetryCount = context.RetryCount, + }; + } + + private static MessageContext BuildMetadata(BasicDeliverEventArgs ea) + { + var props = ea.BasicProperties; + var headers = props.Headers ?? new Dictionary(); + return new MessageContext + { + MessageId = props.MessageId, + CorrelationId = props.CorrelationId ?? string.Empty, + RequestId = props.CorrelationId, + RoutingKey = ea.RoutingKey, + Headers = headers.ToDictionary(), + Redelivered = ea.Redelivered, + RetryCount = RabbitMqConstants.GetRetryCount(headers), + ConversationId = RabbitMqConstants.GetHeaderString(headers, "ConversationId"), + InitiatorId = RabbitMqConstants.GetHeaderString(headers, "InitiatorId"), + SourceAddress = RabbitMqConstants.GetHeaderUri(headers, "SourceAddress"), + DestinationAddress = RabbitMqConstants.GetHeaderUri(headers, "DestinationAddress"), + ResponseAddress = RabbitMqConstants.GetHeaderUri(headers, "ResponseAddress") + ?? (string.IsNullOrEmpty(props.ReplyTo) ? null : new Uri($"queue:{props.ReplyTo}")), + FaultAddress = RabbitMqConstants.GetHeaderUri(headers, "FaultAddress"), + SentTime = props.Timestamp.UnixTime > 0 ? DateTimeOffset.FromUnixTimeSeconds(props.Timestamp.UnixTime) : null, + ExpirationTime = RabbitMqConstants.TryParseExpiration(props.Expiration) + }; + } + + private static MessageContext BuildTypedMetadata( + TMessage message, + BasicDeliverEventArgs ea, + IPublisher? publisher, + ISendEndpointProvider? sendEndpointProvider, + CancellationToken cancellationToken) + { + var props = ea.BasicProperties; + var headers = props.Headers ?? new Dictionary(); + return new MessageContext + { + Message = message, + Publisher = publisher, + SendEndpointProvider = sendEndpointProvider, + CancellationToken = cancellationToken, + MessageId = props.MessageId, + CorrelationId = props.CorrelationId ?? string.Empty, + RequestId = props.CorrelationId, + RoutingKey = ea.RoutingKey, + Headers = headers.ToDictionary(), + Redelivered = ea.Redelivered, + RetryCount = RabbitMqConstants.GetRetryCount(headers), + ConversationId = RabbitMqConstants.GetHeaderString(headers, "ConversationId"), + InitiatorId = RabbitMqConstants.GetHeaderString(headers, "InitiatorId"), + SourceAddress = RabbitMqConstants.GetHeaderUri(headers, "SourceAddress"), + DestinationAddress = RabbitMqConstants.GetHeaderUri(headers, "DestinationAddress"), + ResponseAddress = RabbitMqConstants.GetHeaderUri(headers, "ResponseAddress") + ?? (string.IsNullOrEmpty(props.ReplyTo) ? null : new Uri($"queue:{props.ReplyTo}")), + FaultAddress = RabbitMqConstants.GetHeaderUri(headers, "FaultAddress"), + SentTime = props.Timestamp.UnixTime > 0 ? DateTimeOffset.FromUnixTimeSeconds(props.Timestamp.UnixTime) : null, + ExpirationTime = RabbitMqConstants.TryParseExpiration(props.Expiration) + }; + } +} diff --git a/src/Vulthil.Messaging.RabbitMq/Consumers/MessageHandler.cs b/src/Vulthil.Messaging.RabbitMq/Consumers/MessageHandler.cs index 33e0fc3..b05eac2 100644 --- a/src/Vulthil.Messaging.RabbitMq/Consumers/MessageHandler.cs +++ b/src/Vulthil.Messaging.RabbitMq/Consumers/MessageHandler.cs @@ -23,7 +23,7 @@ internal sealed record MessageHandler /// Dispatches a deserialized message through the consume pipeline and (for RPC) publishes the response on the supplied channel. /// Consumer-kind handlers ignore the channel parameter. The envelope is non-null on the standard receive path /// (Vulthil-produced messages) and null on the bare-JSON compat path (external producers); the closure picks the - /// appropriate MessageContext.CreateContext overload. + /// appropriate MessageContextFactory.CreateContext overload. /// public required Func DispatchAsync { get; init; } } diff --git a/src/Vulthil.Messaging.RabbitMq/Consumers/MessageHandlerFactory.cs b/src/Vulthil.Messaging.RabbitMq/Consumers/MessageHandlerFactory.cs index 3ad32ea..c34ba61 100644 --- a/src/Vulthil.Messaging.RabbitMq/Consumers/MessageHandlerFactory.cs +++ b/src/Vulthil.Messaging.RabbitMq/Consumers/MessageHandlerFactory.cs @@ -32,8 +32,8 @@ public static MessageHandler ForConsumer(RetryPolicyDefinit var publisher = sp.GetRequiredService(); var sendEndpointProvider = sp.GetRequiredService(); var context = envelope is null - ? MessageContext.CreateContext((TMessage)message, ea, publisher, sendEndpointProvider, ct) - : MessageContext.CreateContext((TMessage)message, ea, envelope, publisher, sendEndpointProvider, ct); + ? MessageContextFactory.CreateContext((TMessage)message, ea, publisher, sendEndpointProvider, ct) + : MessageContextFactory.CreateContext((TMessage)message, ea, envelope, publisher, sendEndpointProvider, ct); var pipeline = ConsumePipelineFactory.Build( sp, @@ -62,8 +62,8 @@ public static MessageHandler ForRequestConsumer( var provider = sp.GetRequiredService(); var jsonOptions = provider.JsonSerializerOptions; var context = envelope is null - ? MessageContext.CreateContext((TRequest)message, ea, publisher, sendEndpointProvider, ct) - : MessageContext.CreateContext((TRequest)message, ea, envelope, publisher, sendEndpointProvider, ct); + ? MessageContextFactory.CreateContext((TRequest)message, ea, publisher, sendEndpointProvider, ct) + : MessageContextFactory.CreateContext((TRequest)message, ea, envelope, publisher, sendEndpointProvider, ct); MessageEnvelope reply; try diff --git a/src/Vulthil.Messaging.RabbitMq/Consumers/PartitionKeyExtractorFactory.cs b/src/Vulthil.Messaging.RabbitMq/Consumers/PartitionKeyExtractorFactory.cs index 98ffb7e..25c475a 100644 --- a/src/Vulthil.Messaging.RabbitMq/Consumers/PartitionKeyExtractorFactory.cs +++ b/src/Vulthil.Messaging.RabbitMq/Consumers/PartitionKeyExtractorFactory.cs @@ -29,8 +29,8 @@ internal static class PartitionKeyExtractorFactory // A snapshot context (no live publisher/send provider) is sufficient: key selectors read // metadata and the typed message, they do not publish. var context = envelope is null - ? MessageContext.CreateContext((TMessage)message, ea) - : MessageContext.CreateContext((TMessage)message, ea, envelope, publisher: null, sendEndpointProvider: null, CancellationToken.None); + ? MessageContextFactory.CreateContext((TMessage)message, ea) + : MessageContextFactory.CreateContext((TMessage)message, ea, envelope, publisher: null, sendEndpointProvider: null, CancellationToken.None); return selector(context); }; } diff --git a/src/Vulthil.Messaging.RabbitMq/Consumers/RabbitMqConsumerWorker.cs b/src/Vulthil.Messaging.RabbitMq/Consumers/RabbitMqConsumerWorker.cs index d3a0dba..c152385 100644 --- a/src/Vulthil.Messaging.RabbitMq/Consumers/RabbitMqConsumerWorker.cs +++ b/src/Vulthil.Messaging.RabbitMq/Consumers/RabbitMqConsumerWorker.cs @@ -304,7 +304,7 @@ private async Task PublishFaultIfRequestedAsync(Exception ex, BasicDeliverEventA StackTrace = ex.StackTrace, ExceptionType = ex.GetType().FullName ?? "Unknown", FaultedAt = DateTimeOffset.UtcNow, - OriginalContext = MessageContext.CreateSnapshot(ea) + OriginalContext = MessageContextFactory.CreateSnapshot(ea) }; var faultBody = JsonSerializer.SerializeToUtf8Bytes(fault, _jsonOptions); diff --git a/src/Vulthil.Messaging/PublicAPI.Unshipped.txt b/src/Vulthil.Messaging/PublicAPI.Unshipped.txt index 6501a2c..7068a84 100644 --- a/src/Vulthil.Messaging/PublicAPI.Unshipped.txt +++ b/src/Vulthil.Messaging/PublicAPI.Unshipped.txt @@ -192,6 +192,49 @@ Vulthil.Messaging.Transport.HandlerKind.RequestConsumer = 1 -> Vulthil.Messaging Vulthil.Messaging.Transport.IMessageHandlerFactory Vulthil.Messaging.Transport.IMessageHandlerFactory.ForConsumer(System.Type! consumerType, System.Type! messageType, Vulthil.Messaging.Queues.RetryPolicyDefinition? retryPolicy) -> Vulthil.Messaging.Transport.HandlerEntry! Vulthil.Messaging.Transport.IMessageHandlerFactory.ForRequestConsumer(System.Type! consumerType, System.Type! requestType, System.Type! responseType, Vulthil.Messaging.Queues.RetryPolicyDefinition? retryPolicy) -> Vulthil.Messaging.Transport.HandlerEntry! +Vulthil.Messaging.Transport.MessageContext +Vulthil.Messaging.Transport.MessageContext.CancellationToken.get -> System.Threading.CancellationToken +Vulthil.Messaging.Transport.MessageContext.CancellationToken.init -> void +Vulthil.Messaging.Transport.MessageContext.ConversationId.get -> string? +Vulthil.Messaging.Transport.MessageContext.ConversationId.init -> void +Vulthil.Messaging.Transport.MessageContext.CorrelationId.get -> string! +Vulthil.Messaging.Transport.MessageContext.CorrelationId.init -> void +Vulthil.Messaging.Transport.MessageContext.DestinationAddress.get -> System.Uri? +Vulthil.Messaging.Transport.MessageContext.DestinationAddress.init -> void +Vulthil.Messaging.Transport.MessageContext.ExpirationTime.get -> System.DateTimeOffset? +Vulthil.Messaging.Transport.MessageContext.ExpirationTime.init -> void +Vulthil.Messaging.Transport.MessageContext.FaultAddress.get -> System.Uri? +Vulthil.Messaging.Transport.MessageContext.FaultAddress.init -> void +Vulthil.Messaging.Transport.MessageContext.Headers.get -> System.Collections.Generic.IReadOnlyDictionary! +Vulthil.Messaging.Transport.MessageContext.Headers.init -> void +Vulthil.Messaging.Transport.MessageContext.InitiatorId.get -> string? +Vulthil.Messaging.Transport.MessageContext.InitiatorId.init -> void +Vulthil.Messaging.Transport.MessageContext.MessageId.get -> string? +Vulthil.Messaging.Transport.MessageContext.MessageId.init -> void +Vulthil.Messaging.Transport.MessageContext.PublishAsync(TMessage message, System.Func? configure = null) -> System.Threading.Tasks.Task! +Vulthil.Messaging.Transport.MessageContext.Publisher.get -> Vulthil.Messaging.Abstractions.Publishers.IPublisher? +Vulthil.Messaging.Transport.MessageContext.Publisher.init -> void +Vulthil.Messaging.Transport.MessageContext.RequestId.get -> string? +Vulthil.Messaging.Transport.MessageContext.RequestId.init -> void +Vulthil.Messaging.Transport.MessageContext.Redelivered.get -> bool +Vulthil.Messaging.Transport.MessageContext.Redelivered.init -> void +Vulthil.Messaging.Transport.MessageContext.ResponseAddress.get -> System.Uri? +Vulthil.Messaging.Transport.MessageContext.ResponseAddress.init -> void +Vulthil.Messaging.Transport.MessageContext.RetryCount.get -> int +Vulthil.Messaging.Transport.MessageContext.RetryCount.init -> void +Vulthil.Messaging.Transport.MessageContext.RoutingKey.get -> string! +Vulthil.Messaging.Transport.MessageContext.RoutingKey.init -> void +Vulthil.Messaging.Transport.MessageContext.SendAsync(System.Uri! destinationAddress, TMessage message, System.Func? configure = null) -> System.Threading.Tasks.Task! +Vulthil.Messaging.Transport.MessageContext.SendEndpointProvider.get -> Vulthil.Messaging.Abstractions.Publishers.ISendEndpointProvider? +Vulthil.Messaging.Transport.MessageContext.SendEndpointProvider.init -> void +Vulthil.Messaging.Transport.MessageContext.SentTime.get -> System.DateTimeOffset? +Vulthil.Messaging.Transport.MessageContext.SentTime.init -> void +Vulthil.Messaging.Transport.MessageContext.SourceAddress.get -> System.Uri? +Vulthil.Messaging.Transport.MessageContext.SourceAddress.init -> void +static Vulthil.Messaging.Transport.MessageContext.CreateFromEnvelope(TMessage message, Vulthil.Messaging.Transport.MessageEnvelope! envelope, string! routingKey, bool redelivered, int retryCount, string? replyToFallback, Vulthil.Messaging.Abstractions.Publishers.IPublisher? publisher, Vulthil.Messaging.Abstractions.Publishers.ISendEndpointProvider? sendEndpointProvider, System.Threading.CancellationToken cancellationToken) -> Vulthil.Messaging.Transport.MessageContext! +Vulthil.Messaging.Transport.MessageContext +Vulthil.Messaging.Transport.MessageContext.Message.get -> TMessage +Vulthil.Messaging.Transport.MessageContext.Message.init -> void Vulthil.Messaging.Transport.MessageEnvelope Vulthil.Messaging.Transport.MessageEnvelope.ConversationId.get -> string? Vulthil.Messaging.Transport.MessageEnvelope.ConversationId.init -> void diff --git a/src/Vulthil.Messaging/Transport/MessageContext.cs b/src/Vulthil.Messaging/Transport/MessageContext.cs new file mode 100644 index 0000000..3fdde47 --- /dev/null +++ b/src/Vulthil.Messaging/Transport/MessageContext.cs @@ -0,0 +1,181 @@ +using Vulthil.Messaging.Abstractions.Consumers; +using Vulthil.Messaging.Abstractions.Publishers; + +namespace Vulthil.Messaging.Transport; + +/// +/// Transport-agnostic implementation describing a delivered message and the +/// transport services that back its and +/// helpers. A transport builds one per delivery (typically via ); a +/// context with no live transport binding (e.g. a fault snapshot) throws when a publish or send is attempted. +/// +public record MessageContext : IMessageContext +{ + /// Gets the publisher backing , or for a snapshot context not bound to a live transport. + public IPublisher? Publisher { get; init; } + + /// Gets the send endpoint provider backing , or for a snapshot context not bound to a live transport. + public ISendEndpointProvider? SendEndpointProvider { get; init; } + + /// + public CancellationToken CancellationToken { get; init; } + /// + public string? MessageId { get; init; } + /// + public required string CorrelationId { get; init; } + /// + public string? RequestId { get; init; } + /// + public required string RoutingKey { get; init; } + /// + public required IReadOnlyDictionary Headers { get; init; } + /// + public int RetryCount { get; init; } + /// + public bool Redelivered { get; init; } + /// + public string? ConversationId { get; init; } + /// + public string? InitiatorId { get; init; } + /// + public Uri? SourceAddress { get; init; } + /// + public Uri? DestinationAddress { get; init; } + /// + public Uri? ResponseAddress { get; init; } + /// + public Uri? FaultAddress { get; init; } + /// + public DateTimeOffset? SentTime { get; init; } + /// + public DateTimeOffset? ExpirationTime { get; init; } + + /// + public Task PublishAsync(TMessage message, Func? configure = null) + where TMessage : notnull + => (Publisher ?? throw SnapshotContextError()).PublishAsync( + message, + ctx => PropagateAndConfigureAsync(ctx, configure), + CancellationToken); + + /// + public async Task SendAsync( + Uri destinationAddress, + TMessage message, + Func? configure = null) + where TMessage : notnull + { + ArgumentNullException.ThrowIfNull(destinationAddress); + var provider = SendEndpointProvider ?? throw SnapshotContextError(); + var endpoint = await provider.GetSendEndpointAsync(destinationAddress, CancellationToken); + await endpoint.SendAsync( + message, + ctx => PropagateAndConfigureAsync(ctx, configure), + CancellationToken); + } + + /// + /// Builds a live typed context from a parsed . Transport-level fields the envelope + /// does not carry are supplied by the caller: the broker , the + /// flag, the in-memory , and a + /// used for the response address when the envelope omits one. + /// + /// The deserialized message type. + /// The deserialized message. + /// The parsed wire envelope supplying the message metadata. + /// The broker routing key the message arrived on. + /// Whether the broker flagged this as a redelivery. + /// The current in-memory retry attempt. + /// A reply destination to use when the envelope carries no response address, or . + /// The publisher backing , or . + /// The provider backing , or . + /// The token observed while consuming the message. + /// The constructed typed context. + public static MessageContext CreateFromEnvelope( + TMessage message, + MessageEnvelope envelope, + string routingKey, + bool redelivered, + int retryCount, + string? replyToFallback, + IPublisher? publisher, + ISendEndpointProvider? sendEndpointProvider, + CancellationToken cancellationToken) + { + var userHeaders = envelope.Headers is { } h ? new Dictionary(h) : []; + + return new MessageContext + { + Message = message, + Publisher = publisher, + SendEndpointProvider = sendEndpointProvider, + CancellationToken = cancellationToken, + MessageId = envelope.MessageId, + CorrelationId = envelope.CorrelationId ?? string.Empty, + RequestId = envelope.RequestId ?? envelope.CorrelationId, + RoutingKey = routingKey, + Headers = userHeaders, + Redelivered = redelivered, + RetryCount = retryCount, + ConversationId = envelope.ConversationId, + InitiatorId = envelope.InitiatorId, + SourceAddress = ParseAddress(envelope.SourceAddress), + DestinationAddress = ParseAddress(envelope.DestinationAddress), + ResponseAddress = ParseAddress(envelope.ResponseAddress) + ?? (string.IsNullOrEmpty(replyToFallback) ? null : new Uri($"queue:{replyToFallback}")), + FaultAddress = ParseAddress(envelope.FaultAddress), + SentTime = envelope.SentTime, + ExpirationTime = envelope.ExpirationTime, + }; + } + + private async ValueTask PropagateAndConfigureAsync(IPublishContext ctx, Func? configure) + { + // 1. Auto-propagate correlation metadata from the incoming context first. + if (!string.IsNullOrEmpty(CorrelationId)) + { + ctx.SetCorrelationId(CorrelationId); + } + + var conversationId = ConversationId ?? (string.IsNullOrEmpty(CorrelationId) ? null : CorrelationId); + if (!string.IsNullOrEmpty(conversationId)) + { + ctx.SetConversationId(conversationId); + } + + if (!string.IsNullOrEmpty(MessageId)) + { + ctx.SetInitiatorId(MessageId); + } + + // 2. Caller's configure callback runs last so it can override any auto-set value. + if (configure is not null) + { + await configure(ctx); + } + } + + private static InvalidOperationException SnapshotContextError() => + new("This message context is a snapshot (e.g. a fault envelope) and is not bound to a live transport."); + + private static Uri? ParseAddress(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + return Uri.TryCreate(value, UriKind.Absolute, out var uri) + ? uri + : new Uri($"queue:{value}"); + } +} + +/// +/// A strongly-typed carrying the deserialized message payload. +/// +/// The message type. +public sealed record MessageContext : MessageContext, IMessageContext +{ + /// + public required TMessage Message { get; init; } +} diff --git a/tests/Vulthil.Messaging.RabbitMq.Tests/MessageContextSendTests.cs b/tests/Vulthil.Messaging.RabbitMq.Tests/MessageContextSendTests.cs index 36ebf56..112a6c6 100644 --- a/tests/Vulthil.Messaging.RabbitMq.Tests/MessageContextSendTests.cs +++ b/tests/Vulthil.Messaging.RabbitMq.Tests/MessageContextSendTests.cs @@ -126,7 +126,7 @@ public async Task SendOrPublishOnASnapshotContextThrows() var props = new BasicProperties { CorrelationId = "c", Headers = new Dictionary() }; var ea = new BasicDeliverEventArgs( "consumer-tag", 1, false, "exchange", "routing.key", props, ReadOnlyMemory.Empty); - var snapshot = MessageContext.CreateContext(new TestMessage("payload"), ea); + var snapshot = MessageContextFactory.CreateContext(new TestMessage("payload"), ea); // Act & Assert await Should.ThrowAsync( @@ -162,6 +162,6 @@ private static MessageContext CreateTypedContext( props, ReadOnlyMemory.Empty); - return MessageContext.CreateContext(new TestMessage("payload"), ea, null, sendEndpointProvider, CancellationToken.None); + return MessageContextFactory.CreateContext(new TestMessage("payload"), ea, null, sendEndpointProvider, CancellationToken.None); } } diff --git a/tests/Vulthil.Messaging.RabbitMq.Tests/MessageContextTests.cs b/tests/Vulthil.Messaging.RabbitMq.Tests/MessageContextTests.cs index 8bf4845..7b6d207 100644 --- a/tests/Vulthil.Messaging.RabbitMq.Tests/MessageContextTests.cs +++ b/tests/Vulthil.Messaging.RabbitMq.Tests/MessageContextTests.cs @@ -32,7 +32,7 @@ public void CreateContextShouldMapPropertiesHeadersAndTiming() }); // Act - var context = MessageContext.CreateContext(new TestMessage("payload"), eventArgs); + var context = MessageContextFactory.CreateContext(new TestMessage("payload"), eventArgs); // Assert context.MessageId.ShouldBe("msg-1"); @@ -60,7 +60,7 @@ public void CreateContextShouldFallbackResponseAddressFromReplyTo() var eventArgs = CreateDeliverEventArgs(replyTo: "reply-queue"); // Act - var context = MessageContext.CreateContext(new TestMessage("payload"), eventArgs); + var context = MessageContextFactory.CreateContext(new TestMessage("payload"), eventArgs); // Assert context.ResponseAddress.ShouldBe(new Uri("queue:reply-queue")); @@ -78,7 +78,7 @@ public void CreateContextShouldUseDefaultsWhenPropertiesAreMissing() timestamp: 0); // Act - var context = MessageContext.CreateContext(new TestMessage("payload"), eventArgs); + var context = MessageContextFactory.CreateContext(new TestMessage("payload"), eventArgs); // Assert context.CorrelationId.ShouldBe(string.Empty); @@ -99,7 +99,7 @@ public void CreateContextGenericShouldIncludeTypedMessage() var eventArgs = CreateDeliverEventArgs(routingKey: "typed.route"); // Act - var context = MessageContext.CreateContext(message, eventArgs); + var context = MessageContextFactory.CreateContext(message, eventArgs); // Assert context.Message.ShouldBe(message); @@ -121,7 +121,7 @@ public void CreateSnapshotShouldCaptureTransportMetadata() }); // Act - var snapshot = MessageContext.CreateSnapshot(eventArgs); + var snapshot = MessageContextFactory.CreateSnapshot(eventArgs); // Assert snapshot.MessageId.ShouldBe("msg-1"); diff --git a/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqConsumerWorkerTests.cs b/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqConsumerWorkerTests.cs index f3e4962..9f8e11c 100644 --- a/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqConsumerWorkerTests.cs +++ b/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqConsumerWorkerTests.cs @@ -22,7 +22,7 @@ public void WithRetryCountSurfacesTheAttemptThroughMessageContextRetryCount() // Act — the worker rewrites the delivery for the third in-memory attempt. var retried = RabbitMqConsumerWorker.WithRetryCount(delivery, 3); - var context = MessageContext.CreateContext(new TestMessage("payload"), retried); + var context = MessageContextFactory.CreateContext(new TestMessage("payload"), retried); // Assert context.RetryCount.ShouldBe(3); From e29995a2e0e399082f79bfebb2967daa375dc1c8 Mon Sep 17 00:00:00 2001 From: Vulthil Date: Thu, 4 Jun 2026 21:49:34 +0200 Subject: [PATCH 28/42] feat(messaging): make QueueDefinition consumer/subscription registration public --- src/Vulthil.Messaging/PublicAPI.Unshipped.txt | 2 ++ src/Vulthil.Messaging/Queues/QueueDefinition.cs | 12 ++++++++++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/Vulthil.Messaging/PublicAPI.Unshipped.txt b/src/Vulthil.Messaging/PublicAPI.Unshipped.txt index 7068a84..feb7fb4 100644 --- a/src/Vulthil.Messaging/PublicAPI.Unshipped.txt +++ b/src/Vulthil.Messaging/PublicAPI.Unshipped.txt @@ -99,6 +99,8 @@ Vulthil.Messaging.Queues.MessageType.Name.get -> string! Vulthil.Messaging.Queues.MessageType.Type.get -> System.Type! Vulthil.Messaging.Queues.MessageType.Type.init -> void Vulthil.Messaging.Queues.QueueDefinition +Vulthil.Messaging.Queues.QueueDefinition.AddConsumer(Vulthil.Messaging.Queues.Registration! registration) -> void +Vulthil.Messaging.Queues.QueueDefinition.AddSubscription(Vulthil.Messaging.Queues.Subscription! subscription) -> void Vulthil.Messaging.Queues.QueueDefinition.AutoDelete.get -> bool Vulthil.Messaging.Queues.QueueDefinition.AutoDelete.set -> void Vulthil.Messaging.Queues.QueueDefinition.ChannelCount.get -> ushort diff --git a/src/Vulthil.Messaging/Queues/QueueDefinition.cs b/src/Vulthil.Messaging/Queues/QueueDefinition.cs index 90f350d..1847709 100644 --- a/src/Vulthil.Messaging/Queues/QueueDefinition.cs +++ b/src/Vulthil.Messaging/Queues/QueueDefinition.cs @@ -182,9 +182,17 @@ public sealed record QueueDefinition(string Name) public bool RetryEnabled => DefaultRetryPolicy is not null || Registrations.Any(r => r.RetryPolicy is not null); - internal void AddConsumer(Registration registration) + /// + /// Adds a consumer registration to this queue. Duplicate registrations (by value) are ignored. + /// + /// The consumer-to-message binding to add. + public void AddConsumer(Registration registration) => _registrations.Add(registration); - internal void AddSubscription(Subscription subscription) + /// + /// Adds an exchange→queue binding to this queue. Duplicate subscriptions (by value) are ignored. + /// + /// The subscription describing the binding to add. + public void AddSubscription(Subscription subscription) => _subscriptions.Add(subscription); } From d165661fba4d714b10a281f29eec438fda698643 Mon Sep 17 00:00:00 2001 From: Vulthil Date: Thu, 4 Jun 2026 22:02:02 +0200 Subject: [PATCH 29/42] test(messaging): cover transport primitives in core test project --- .../Transport/ConsumePipelineFactoryTests.cs | 97 +++++++++ .../Transport/MessageContextTests.cs | 144 +++++++++++++ .../Transport}/MessageEnvelopeTests.cs | 2 +- .../MessageExecutionRegistryTests.cs | 199 ++++++++++++++++++ 4 files changed, 441 insertions(+), 1 deletion(-) create mode 100644 tests/Vulthil.Messaging.Tests/Transport/ConsumePipelineFactoryTests.cs create mode 100644 tests/Vulthil.Messaging.Tests/Transport/MessageContextTests.cs rename tests/{Vulthil.Messaging.RabbitMq.Tests => Vulthil.Messaging.Tests/Transport}/MessageEnvelopeTests.cs (98%) create mode 100644 tests/Vulthil.Messaging.Tests/Transport/MessageExecutionRegistryTests.cs diff --git a/tests/Vulthil.Messaging.Tests/Transport/ConsumePipelineFactoryTests.cs b/tests/Vulthil.Messaging.Tests/Transport/ConsumePipelineFactoryTests.cs new file mode 100644 index 0000000..9a5cfd2 --- /dev/null +++ b/tests/Vulthil.Messaging.Tests/Transport/ConsumePipelineFactoryTests.cs @@ -0,0 +1,97 @@ +using Microsoft.Extensions.DependencyInjection; +using Vulthil.Messaging.Abstractions.Consumers; +using Vulthil.Messaging.Transport; +using Vulthil.xUnit; + +namespace Vulthil.Messaging.Tests.Transport; + +public sealed class ConsumePipelineFactoryTests : BaseUnitTestCase +{ + private sealed record TestMessage(string Value); + + private sealed class RecordingFilter(string name, List log, bool shortCircuit = false) : IConsumeFilter + { + public async Task ConsumeAsync(IMessageContext context, ConsumeDelegate next) + { + log.Add($"{name}:before"); + if (!shortCircuit) + { + await next(context); + } + log.Add($"{name}:after"); + } + } + + private static MessageContext CreateContext() => new() + { + Message = new TestMessage("x"), + CorrelationId = "c", + RoutingKey = "r", + Headers = new Dictionary(), + }; + + [Fact] + public async Task BuildWithoutFiltersReturnsTerminalUnwrapped() + { + // Arrange + var log = new List(); + var serviceProvider = new ServiceCollection().BuildServiceProvider(); + ConsumeDelegate terminal = _ => + { + log.Add("terminal"); + return Task.CompletedTask; + }; + + // Act + var pipeline = ConsumePipelineFactory.Build(serviceProvider, terminal); + await pipeline(CreateContext()); + + // Assert + log.ShouldBe(["terminal"]); + } + + [Fact] + public async Task BuildComposesFiltersFirstRegisteredOutermost() + { + // Arrange + var log = new List(); + var serviceProvider = new ServiceCollection() + .AddSingleton>(new RecordingFilter("outer", log)) + .AddSingleton>(new RecordingFilter("inner", log)) + .BuildServiceProvider(); + ConsumeDelegate terminal = _ => + { + log.Add("terminal"); + return Task.CompletedTask; + }; + + // Act + var pipeline = ConsumePipelineFactory.Build(serviceProvider, terminal); + await pipeline(CreateContext()); + + // Assert + log.ShouldBe(["outer:before", "inner:before", "terminal", "inner:after", "outer:after"]); + } + + [Fact] + public async Task BuildShortCircuitsWhenFilterSkipsNext() + { + // Arrange + var log = new List(); + var serviceProvider = new ServiceCollection() + .AddSingleton>(new RecordingFilter("gate", log, shortCircuit: true)) + .BuildServiceProvider(); + ConsumeDelegate terminal = _ => + { + log.Add("terminal"); + return Task.CompletedTask; + }; + + // Act + var pipeline = ConsumePipelineFactory.Build(serviceProvider, terminal); + await pipeline(CreateContext()); + + // Assert + log.ShouldBe(["gate:before", "gate:after"]); + } +} diff --git a/tests/Vulthil.Messaging.Tests/Transport/MessageContextTests.cs b/tests/Vulthil.Messaging.Tests/Transport/MessageContextTests.cs new file mode 100644 index 0000000..6ece92d --- /dev/null +++ b/tests/Vulthil.Messaging.Tests/Transport/MessageContextTests.cs @@ -0,0 +1,144 @@ +using System.Text.Json; +using Vulthil.Messaging.Transport; +using Vulthil.xUnit; + +namespace Vulthil.Messaging.Tests.Transport; + +public sealed class MessageContextTests : BaseUnitTestCase +{ + private sealed record TestMessage(string Content); + + private static MessageEnvelope Envelope( + string? requestId = "req-1", + string? responseAddress = "queue:reply") => new() + { + MessageId = "msg-1", + RequestId = requestId, + CorrelationId = "corr-1", + ConversationId = "conv-1", + InitiatorId = "init-1", + SourceAddress = "queue:source", + DestinationAddress = "amqp://broker/destination", + ResponseAddress = responseAddress, + FaultAddress = "queue:faults", + MessageType = new Uri("urn:message:TestMessage"), + Message = JsonSerializer.SerializeToElement(new TestMessage("payload")), + SentTime = DateTimeOffset.FromUnixTimeSeconds(1_700_000_000), + ExpirationTime = DateTimeOffset.FromUnixTimeSeconds(1_700_000_300), + Headers = new Dictionary { ["tenant"] = "acme" }, + }; + + [Fact] + public void CreateFromEnvelopeMapsEnvelopeAndTransportFields() + { + // Arrange + var message = new TestMessage("payload"); + + // Act + var context = MessageContext.CreateFromEnvelope( + message, + Envelope(), + routingKey: "orders.created", + redelivered: true, + retryCount: 2, + replyToFallback: "ignored-because-envelope-has-one", + publisher: null, + sendEndpointProvider: null, + CancellationToken.None); + + // Assert + context.Message.ShouldBe(message); + context.MessageId.ShouldBe("msg-1"); + context.RequestId.ShouldBe("req-1"); + context.CorrelationId.ShouldBe("corr-1"); + context.ConversationId.ShouldBe("conv-1"); + context.InitiatorId.ShouldBe("init-1"); + context.RoutingKey.ShouldBe("orders.created"); + context.Redelivered.ShouldBeTrue(); + context.RetryCount.ShouldBe(2); + context.SourceAddress.ShouldBe(new Uri("queue:source")); + context.DestinationAddress.ShouldBe(new Uri("amqp://broker/destination")); + context.ResponseAddress.ShouldBe(new Uri("queue:reply")); + context.FaultAddress.ShouldBe(new Uri("queue:faults")); + context.SentTime.ShouldBe(DateTimeOffset.FromUnixTimeSeconds(1_700_000_000)); + context.ExpirationTime.ShouldBe(DateTimeOffset.FromUnixTimeSeconds(1_700_000_300)); + context.Headers["tenant"]!.ToString().ShouldBe("acme"); + } + + [Fact] + public void CreateFromEnvelopeFallsBackToReplyToWhenEnvelopeHasNoResponseAddress() + { + // Act + var context = MessageContext.CreateFromEnvelope( + new TestMessage("payload"), + Envelope(responseAddress: null), + routingKey: "rk", + redelivered: false, + retryCount: 0, + replyToFallback: "reply-queue", + publisher: null, + sendEndpointProvider: null, + CancellationToken.None); + + // Assert + context.ResponseAddress.ShouldBe(new Uri("queue:reply-queue")); + } + + [Fact] + public void CreateFromEnvelopeFallsBackToCorrelationIdWhenRequestIdIsMissing() + { + // Act + var context = MessageContext.CreateFromEnvelope( + new TestMessage("payload"), + Envelope(requestId: null), + routingKey: "rk", + redelivered: false, + retryCount: 0, + replyToFallback: null, + publisher: null, + sendEndpointProvider: null, + CancellationToken.None); + + // Assert + context.RequestId.ShouldBe("corr-1"); + } + + [Fact] + public async Task PublishOnAContextWithoutATransportThrows() + { + // Arrange + var context = MessageContext.CreateFromEnvelope( + new TestMessage("payload"), + Envelope(), + routingKey: "rk", + redelivered: false, + retryCount: 0, + replyToFallback: null, + publisher: null, + sendEndpointProvider: null, + CancellationToken.None); + + // Act & Assert + await Should.ThrowAsync(() => context.PublishAsync(new TestMessage("x"))); + } + + [Fact] + public async Task SendOnAContextWithoutATransportThrows() + { + // Arrange + var context = MessageContext.CreateFromEnvelope( + new TestMessage("payload"), + Envelope(), + routingKey: "rk", + redelivered: false, + retryCount: 0, + replyToFallback: null, + publisher: null, + sendEndpointProvider: null, + CancellationToken.None); + + // Act & Assert + await Should.ThrowAsync( + () => context.SendAsync(new Uri("queue:dest"), new TestMessage("x"))); + } +} diff --git a/tests/Vulthil.Messaging.RabbitMq.Tests/MessageEnvelopeTests.cs b/tests/Vulthil.Messaging.Tests/Transport/MessageEnvelopeTests.cs similarity index 98% rename from tests/Vulthil.Messaging.RabbitMq.Tests/MessageEnvelopeTests.cs rename to tests/Vulthil.Messaging.Tests/Transport/MessageEnvelopeTests.cs index ab2911b..e4bad85 100644 --- a/tests/Vulthil.Messaging.RabbitMq.Tests/MessageEnvelopeTests.cs +++ b/tests/Vulthil.Messaging.Tests/Transport/MessageEnvelopeTests.cs @@ -2,7 +2,7 @@ using Vulthil.Messaging.Transport; using Vulthil.xUnit; -namespace Vulthil.Messaging.RabbitMq.Tests; +namespace Vulthil.Messaging.Tests.Transport; public sealed class MessageEnvelopeTests : BaseUnitTestCase { diff --git a/tests/Vulthil.Messaging.Tests/Transport/MessageExecutionRegistryTests.cs b/tests/Vulthil.Messaging.Tests/Transport/MessageExecutionRegistryTests.cs new file mode 100644 index 0000000..d5a444a --- /dev/null +++ b/tests/Vulthil.Messaging.Tests/Transport/MessageExecutionRegistryTests.cs @@ -0,0 +1,199 @@ +using Vulthil.Messaging.Abstractions.Consumers; +using Vulthil.Messaging.Queues; +using Vulthil.Messaging.Transport; +using Vulthil.xUnit; + +namespace Vulthil.Messaging.Tests.Transport; + +/// A transport-specific handler stand-in for exercising the agnostic registry. +public sealed record FakeHandler(string Label); + +/// A fake factory that labels handlers by their registration shape, so tests can assert what was built. +public sealed class FakeHandlerFactory : IMessageHandlerFactory +{ + public HandlerEntry ForConsumer(Type consumerType, Type messageType, RetryPolicyDefinition? retryPolicy) + => new(new FakeHandler($"consumer:{consumerType.Name}:{messageType.Name}"), HandlerKind.Consumer); + + public HandlerEntry ForRequestConsumer(Type consumerType, Type requestType, Type responseType, RetryPolicyDefinition? retryPolicy) + => new(new FakeHandler($"request:{consumerType.Name}:{requestType.Name}"), HandlerKind.RequestConsumer); +} + +public sealed class MessageExecutionRegistryTests : BaseUnitTestCase> +{ + public MessageExecutionRegistryTests() + { + GetMock() + .Setup(p => p.GetUrn(It.IsAny())) + .Returns(t => new Uri($"urn:test:{t.FullName}")); + Use>(new FakeHandlerFactory()); + } + + private abstract record OrderEvent(string Id); + private sealed record OrderPlaced(string Id) : OrderEvent(Id); + private sealed record OrderShipped(string Id) : OrderEvent(Id); + private sealed record Ping(string Id); + private sealed record Pong(string Id); + + private sealed class OrderConsumer : IConsumer + { + public Task ConsumeAsync(IMessageContext messageContext, CancellationToken cancellationToken = default) + => Task.CompletedTask; + } + + private sealed class PingConsumer : IRequestConsumer + { + public Task ConsumeAsync(IMessageContext messageContext, CancellationToken cancellationToken = default) + => Task.FromResult(new Pong(messageContext.Message.Id)); + } + + private sealed class OtherPingConsumer : IRequestConsumer + { + public Task ConsumeAsync(IMessageContext messageContext, CancellationToken cancellationToken = default) + => Task.FromResult(new Pong(messageContext.Message.Id)); + } + + private static QueueDefinition Queue(string name = "test-queue") => new(name); + + [Fact] + public void RegisterQueueBuildsAPlanWithTheConsumerHandler() + { + // Arrange + var queue = Queue(); + queue.AddConsumer(new ConsumerRegistration + { + ConsumerType = new ConsumerType(typeof(OrderConsumer)), + MessageType = new MessageType(typeof(OrderPlaced)), + }); + + // Act + Target.RegisterQueue(queue); + + // Assert + var plan = Target.GetPlan(new MessageType(typeof(OrderPlaced)).Name); + plan.ShouldNotBeNull(); + plan.Handlers.ShouldHaveSingleItem(); + plan.IsPartitioned.ShouldBeFalse(); + } + + [Fact] + public void RegisterQueueDedupesIdenticalRegistrationsIntoOneHandler() + { + // Arrange + var queue = Queue(); + var registration = new ConsumerRegistration + { + ConsumerType = new ConsumerType(typeof(OrderConsumer)), + MessageType = new MessageType(typeof(OrderPlaced)), + }; + queue.AddConsumer(registration); + queue.AddConsumer(registration); + + // Act + Target.RegisterQueue(queue); + + // Assert + Target.GetPlan(new MessageType(typeof(OrderPlaced)).Name)!.Handlers.ShouldHaveSingleItem(); + } + + [Fact] + public void RegisterQueueRejectsASecondRequestConsumerForTheSameMessageType() + { + // Arrange + var queue = Queue(); + queue.AddConsumer(new RequestConsumerRegistration + { + ConsumerType = new ConsumerType(typeof(PingConsumer)), + MessageType = new MessageType(typeof(Ping)), + ResponseType = typeof(Pong), + }); + queue.AddConsumer(new RequestConsumerRegistration + { + ConsumerType = new ConsumerType(typeof(OtherPingConsumer)), + MessageType = new MessageType(typeof(Ping)), + ResponseType = typeof(Pong), + }); + + // Act & Assert + var exception = Should.Throw(() => Target.RegisterQueue(queue)); + exception.Message.ShouldContain("request consumer"); + exception.Message.ShouldContain("test-queue"); + } + + [Fact] + public void RegisterQueueFansAPolymorphicRegistrationOutAcrossConcreteSubscriptions() + { + // Arrange + var queue = Queue(); + queue.AddSubscription(new Subscription(new MessageType(typeof(OrderPlaced)))); + queue.AddSubscription(new Subscription(new MessageType(typeof(OrderShipped)))); + queue.AddConsumer(new ConsumerRegistration + { + ConsumerType = new ConsumerType(typeof(OrderConsumer)), + MessageType = new MessageType(typeof(OrderEvent)), + }); + + // Act + Target.RegisterQueue(queue); + + // Assert + Target.GetPlan(new MessageType(typeof(OrderPlaced)).Name)!.Handlers.ShouldHaveSingleItem(); + Target.GetPlan(new MessageType(typeof(OrderShipped)).Name)!.Handlers.ShouldHaveSingleItem(); + } + + [Fact] + public void GetPlanReturnsNullForAnUnregisteredMessageType() + { + // Act + var plan = Target.GetPlan("urn:test:does.not.exist"); + + // Assert + plan.ShouldBeNull(); + } + + [Fact] + public void GetPlanByUrnResolvesTheSamePlanAsTheFullNameLookup() + { + // Arrange + var queue = Queue(); + queue.AddConsumer(new ConsumerRegistration + { + ConsumerType = new ConsumerType(typeof(OrderConsumer)), + MessageType = new MessageType(typeof(OrderPlaced)), + }); + Target.RegisterQueue(queue); + + // Act + var byUrn = Target.GetPlanByUrn(new Uri($"urn:test:{typeof(OrderPlaced).FullName}")); + var byFullName = Target.GetPlanByFullName(typeof(OrderPlaced).FullName!); + + // Assert + byUrn.ShouldNotBeNull(); + byUrn.ShouldBeSameAs(byFullName); + } + + [Fact] + public void RegisterQueueAttachesThePartitionSpecForPartitionedTypes() + { + // Arrange + var spec = new PartitionSpec(new Partitioner(4), (Func)(o => o.Id)); + GetMock() + .Setup(p => p.GetPartition(typeof(OrderPlaced))) + .Returns(spec); + + var queue = Queue(); + queue.AddConsumer(new ConsumerRegistration + { + ConsumerType = new ConsumerType(typeof(OrderConsumer)), + MessageType = new MessageType(typeof(OrderPlaced)), + }); + + // Act + Target.RegisterQueue(queue); + + // Assert + var plan = Target.GetPlan(new MessageType(typeof(OrderPlaced)).Name); + plan.ShouldNotBeNull(); + plan.Partition.ShouldBeSameAs(spec); + plan.IsPartitioned.ShouldBeTrue(); + } +} From eb9ee37a1906645f7088bcd2ca49373b7ebcae06 Mon Sep 17 00:00:00 2001 From: Vulthil Date: Thu, 4 Jun 2026 22:09:37 +0200 Subject: [PATCH 30/42] refactor(messaging): build RabbitMq test providers via public AddMessaging and drop the IVT --- .../Vulthil.Messaging.csproj | 4 -- .../ConsumeFilterPipelineTests.cs | 6 +-- .../MessageTypeCacheTests.cs | 2 +- .../PolymorphicDispatchTests.cs | 2 +- .../RabbitMqBusTopologyTests.cs | 44 +++++++++---------- .../RabbitMqRequesterTests.cs | 3 +- .../TestProviders.cs | 24 ++++++++++ 7 files changed, 51 insertions(+), 34 deletions(-) create mode 100644 tests/Vulthil.Messaging.RabbitMq.Tests/TestProviders.cs diff --git a/src/Vulthil.Messaging/Vulthil.Messaging.csproj b/src/Vulthil.Messaging/Vulthil.Messaging.csproj index 0d97a5f..efec166 100644 --- a/src/Vulthil.Messaging/Vulthil.Messaging.csproj +++ b/src/Vulthil.Messaging/Vulthil.Messaging.csproj @@ -16,8 +16,4 @@ - - - - diff --git a/tests/Vulthil.Messaging.RabbitMq.Tests/ConsumeFilterPipelineTests.cs b/tests/Vulthil.Messaging.RabbitMq.Tests/ConsumeFilterPipelineTests.cs index 5cab3c6..fd46ff7 100644 --- a/tests/Vulthil.Messaging.RabbitMq.Tests/ConsumeFilterPipelineTests.cs +++ b/tests/Vulthil.Messaging.RabbitMq.Tests/ConsumeFilterPipelineTests.cs @@ -18,7 +18,7 @@ public sealed class ConsumeFilterPipelineTests : BaseUnitTestCase public ConsumeFilterPipelineTests() { - UseRealFor(); + Use(TestProviders.Build()); _lazyTarget = new Lazy(CreateInstance); } @@ -190,7 +190,7 @@ public async Task RpcPipelineComposesFiltersAroundConsumerCall() services.AddScoped(_ => consumerInstance); services.AddSingleton(Mock.Of()); services.AddSingleton(Mock.Of()); - services.AddSingleton(new MessagingOptions()); + services.AddSingleton(TestProviders.Build()); services.AddScoped>(_ => new RecordingFilter(trace, "log")); var serviceProvider = services.BuildServiceProvider(); @@ -249,7 +249,7 @@ public async Task RpcPipelineShortCircuitProducesFailureResponse() services.AddScoped(_ => consumerInstance); services.AddSingleton(Mock.Of()); services.AddSingleton(Mock.Of()); - services.AddSingleton(new MessagingOptions()); + services.AddSingleton(TestProviders.Build()); services.AddScoped>(_ => new RecordingFilter([], "gate") { ShortCircuit = true }); var serviceProvider = services.BuildServiceProvider(); diff --git a/tests/Vulthil.Messaging.RabbitMq.Tests/MessageTypeCacheTests.cs b/tests/Vulthil.Messaging.RabbitMq.Tests/MessageTypeCacheTests.cs index 1a4e67d..cba83e5 100644 --- a/tests/Vulthil.Messaging.RabbitMq.Tests/MessageTypeCacheTests.cs +++ b/tests/Vulthil.Messaging.RabbitMq.Tests/MessageTypeCacheTests.cs @@ -19,7 +19,7 @@ public sealed class MessageTypeCacheTests : BaseUnitTestCase public MessageTypeCacheTests() { _lazyTarget = new Lazy(CreateInstance); - UseRealFor(); + Use(TestProviders.Build()); Use>>([]); Use>>([]); diff --git a/tests/Vulthil.Messaging.RabbitMq.Tests/PolymorphicDispatchTests.cs b/tests/Vulthil.Messaging.RabbitMq.Tests/PolymorphicDispatchTests.cs index d331c5c..c92e1af 100644 --- a/tests/Vulthil.Messaging.RabbitMq.Tests/PolymorphicDispatchTests.cs +++ b/tests/Vulthil.Messaging.RabbitMq.Tests/PolymorphicDispatchTests.cs @@ -16,7 +16,7 @@ public sealed class PolymorphicDispatchTests : BaseUnitTestCase public PolymorphicDispatchTests() { - UseRealFor(); + Use(TestProviders.Build()); _lazyTarget = new Lazy(CreateInstance); } diff --git a/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqBusTopologyTests.cs b/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqBusTopologyTests.cs index b7ec68d..7174929 100644 --- a/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqBusTopologyTests.cs +++ b/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqBusTopologyTests.cs @@ -39,35 +39,35 @@ public RabbitMqBusTopologyTests() Use>(NullLogger.Instance); } - private async Task DeclareTopologyAsync(MessagingOptions options) + private async Task DeclareTopologyAsync(IMessageConfigurationProvider provider) { - Use(options); + Use(provider); await using var bus = CreateInstance(); await bus.StartAsync(CancellationToken); } - private static MessagingOptions OptionsConsumingOrderedEvents(string queueName) - { - var queue = new QueueDefinition(queueName); - queue.AddConsumer(new ConsumerRegistration + private static IMessageConfigurationProvider ProviderConsumingOrderedEvents( + string queueName, + Action? configureQueue = null, + Action? configure = null) + => TestProviders.Build(cfg => { - ConsumerType = new ConsumerType(typeof(OrderedConsumer)), - MessageType = new MessageType(typeof(OrderedMessage)), + cfg.ConfigureQueue(queueName, queue => + { + queue.AddConsumer(); + configureQueue?.Invoke(queue); + }); + configure?.Invoke(cfg); }); - var options = new MessagingOptions(); - options.QueueDefinitions[queue.Name] = queue; - return options; - } - [Fact] public async Task PlainQueueIsDeclaredWithoutSingleActiveConsumerArgument() { // Arrange - var options = OptionsConsumingOrderedEvents("plain"); + var provider = ProviderConsumingOrderedEvents("plain"); // Act - await DeclareTopologyAsync(options); + await DeclareTopologyAsync(provider); // Assert _declaredQueues.ShouldContainKey("plain"); @@ -78,11 +78,10 @@ public async Task PlainQueueIsDeclaredWithoutSingleActiveConsumerArgument() public async Task ExplicitlyConfiguredQueueIsDeclaredWithSingleActiveConsumerArgument() { // Arrange - var options = OptionsConsumingOrderedEvents("sole"); - options.QueueDefinitions["sole"].SingleActiveConsumer = true; + var provider = ProviderConsumingOrderedEvents("sole", configureQueue: queue => queue.UseSingleActiveConsumer()); // Act - await DeclareTopologyAsync(options); + await DeclareTopologyAsync(provider); // Assert _declaredQueues["sole"][SingleActiveConsumerArgument].ShouldBe(true); @@ -92,13 +91,12 @@ public async Task ExplicitlyConfiguredQueueIsDeclaredWithSingleActiveConsumerArg public async Task PartitionedQueueAutomaticallyEnablesSingleActiveConsumer() { // Arrange - var options = OptionsConsumingOrderedEvents("ordered"); - options.RegisterPartition( - typeof(OrderedMessage), - new PartitionSpec(new Partitioner(4), (Func, string?>)(context => context.CorrelationId))); + var provider = ProviderConsumingOrderedEvents( + "ordered", + configure: cfg => cfg.UsePartitioner(new Partitioner(4), context => context.CorrelationId)); // Act - await DeclareTopologyAsync(options); + await DeclareTopologyAsync(provider); // Assert _declaredQueues["ordered"][SingleActiveConsumerArgument].ShouldBe(true); diff --git a/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqRequesterTests.cs b/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqRequesterTests.cs index 8122ff4..1ba370f 100644 --- a/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqRequesterTests.cs +++ b/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqRequesterTests.cs @@ -16,8 +16,7 @@ public sealed class RabbitMqRequesterTests : BaseUnitTestCase public RabbitMqRequesterTests() { - var options = new MessagingOptions(); - Use(options); + Use(TestProviders.Build()); var channelMock = GetMock(); channelMock diff --git a/tests/Vulthil.Messaging.RabbitMq.Tests/TestProviders.cs b/tests/Vulthil.Messaging.RabbitMq.Tests/TestProviders.cs new file mode 100644 index 0000000..a768507 --- /dev/null +++ b/tests/Vulthil.Messaging.RabbitMq.Tests/TestProviders.cs @@ -0,0 +1,24 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Vulthil.Messaging.RabbitMq.Tests; + +/// +/// Builds a real through the public AddMessaging entry point, +/// so transport tests exercise the same provider the host wires up — without reaching into messaging internals. +/// +internal static class TestProviders +{ + /// + /// Builds a provider, optionally applying to register queues, consumers, and partitions. + /// + /// Optional messaging configuration callback. + /// The resolved configuration provider. + public static IMessageConfigurationProvider Build(Action? configure = null) + { + var builder = Host.CreateApplicationBuilder(); + builder.AddMessaging(configure ?? (_ => { })); + using var serviceProvider = builder.Services.BuildServiceProvider(); + return serviceProvider.GetRequiredService(); + } +} From 0694f891b0fa744598e48e1476fe99f8b7685e6d Mon Sep 17 00:00:00 2001 From: Vulthil Date: Thu, 4 Jun 2026 22:21:52 +0200 Subject: [PATCH 31/42] docs(messaging): document writing a custom transport --- docs/articles/messaging.md | 135 +++++++++++++++++++++++++++++++- src/Vulthil.Messaging/README.md | 4 + 2 files changed, 136 insertions(+), 3 deletions(-) diff --git a/docs/articles/messaging.md b/docs/articles/messaging.md index 8e13449..43f479b 100644 --- a/docs/articles/messaging.md +++ b/docs/articles/messaging.md @@ -7,10 +7,13 @@ The messaging packages provide a transport-agnostic abstraction for asynchronous | Package | Role | |---|---| | `Vulthil.Messaging.Abstractions` | Consumer and publisher interfaces – reference this from domain/application projects | -| `Vulthil.Messaging` | Queue registration, consumer wiring, and hosted service orchestration | +| `Vulthil.Messaging` | Queue registration, consumer wiring, hosted orchestration, and the transport-author SDK (`Vulthil.Messaging.Transport`) | | `Vulthil.Messaging.RabbitMq` | RabbitMQ transport implementation | | `Vulthil.Messaging.TestHarness` | In-memory transport for integration tests | +The `Vulthil.Messaging.Transport` namespace is a *build-your-own-transport* SDK — see +[Writing a Custom Transport](#writing-a-custom-transport). + ## Defining Consumers ### One-way consumer @@ -471,8 +474,8 @@ messaging.ConfigureMessage(m => ``` The URN is the dispatch key on the receive side — it appears in the message -envelope's `messageType` field, and `MessageTypeCache` keys its execution plans -by URN. +envelope's `messageType` field, and `MessageExecutionRegistry` keys its +execution plans by URN. ### Message envelope (wire format) @@ -746,6 +749,132 @@ The reply is a normal `MessageEnvelope` (single-serialized, like every other mes type and message); the requester maps it to a `Result` failure with the `Messaging.Request.Failure` error code. +## Writing a Custom Transport + +`Vulthil.Messaging` is also a *build-your-own-transport* SDK. The transport-agnostic +primitives live in the **`Vulthil.Messaging.Transport`** namespace, so a transport for a +broker other than RabbitMQ can be written in a separate package against the public surface +alone — the RabbitMQ transport uses nothing more. + +A transport is the glue between the broker and these primitives: + +| Concern | Primitive | +|---|---| +| Lifetime | `ITransport.StartAsync` — declare topology, then start consuming | +| Execution plans | `MessageExecutionRegistry` + your `IMessageHandlerFactory` | +| Wire format | `MessageEnvelope` + `MessageEnvelopeFactory.Create` | +| Receive context | `MessageContext.CreateFromEnvelope` | +| Filter pipeline | `ConsumePipelineFactory.Build` | +| RPC failures | `RpcFault` | + +### 1. Build execution plans + +Choose a `THandler` type for your transport's dispatch closure, then implement +`IMessageHandlerFactory` to turn each registration into one. The factory is where the +message type is statically known, so it is also where you compose the filter pipeline and build +the receive context: + +```csharp +public delegate Task Dispatch(IServiceProvider scope, object message, MessageEnvelope envelope, CancellationToken ct); + +internal sealed class MyHandlerFactory : IMessageHandlerFactory +{ + public HandlerEntry ForConsumer(Type consumer, Type message, RetryPolicyDefinition? retry) + => new(BuildConsumer(consumer, message), HandlerKind.Consumer); + + public HandlerEntry ForRequestConsumer(Type consumer, Type request, Type response, RetryPolicyDefinition? retry) + => new(BuildRequestConsumer(consumer, request, response), HandlerKind.RequestConsumer); + + // Bound generically (e.g. via reflection) so TMessage is known here: + private static Dispatch Consumer() where TConsumer : class, IConsumer where TMessage : notnull + => async (scope, message, envelope, ct) => + { + var consumer = scope.GetRequiredService(); + var context = MessageContext.CreateFromEnvelope( + (TMessage)message, envelope, routingKey: "", redelivered: false, + retryCount: 0, replyToFallback: null, + scope.GetRequiredService(), scope.GetRequiredService(), ct); + + var pipeline = ConsumePipelineFactory.Build(scope, c => consumer.ConsumeAsync(c, c.CancellationToken)); + await pipeline(context); + }; +} +``` + +Let `MessageExecutionRegistry` assemble the per-message-type plans from the configured +queues — it handles URN keying, polymorphic fan-out, deduplication, request-consumer uniqueness, +and partition attachment: + +```csharp +var registry = new MessageExecutionRegistry(provider, new MyHandlerFactory()); +foreach (var queue in provider.QueueDefinitions) +{ + registry.RegisterQueue(queue); +} +``` + +### 2. Produce + +Wrap each outgoing message in a `MessageEnvelope`. `MessageEnvelopeFactory.Create` promotes the +publish context's metadata to typed envelope fields and serializes the payload: + +```csharp +var envelope = MessageEnvelopeFactory.Create( + message, publishContext, messageId, correlationId, urn, provider.JsonSerializerOptions); +var body = JsonSerializer.SerializeToUtf8Bytes(envelope, provider.JsonSerializerOptions); +``` + +`PublishContext`/`RequestContext` implement the `IPublishContext`/`IRequestContext` the caller's +`configure` callback writes to; read their resolved properties (`RoutingKey`, `CorrelationId`, +`Headers`, …) when building the broker message. + +### 3. Consume + +In the receive loop, parse the envelope, resolve the plan by URN, deserialize the payload, then +run the plan's handlers: + +```csharp +var envelope = JsonSerializer.Deserialize(body, provider.JsonSerializerOptions)!; +var plan = registry.GetPlanByUrn(envelope.MessageType); +if (plan is null) { return; } // unknown type — drop or dead-letter + +var message = envelope.Message.Deserialize(plan.MessageType.Type, provider.JsonSerializerOptions)!; + +await using var scope = scopeFactory.CreateAsyncScope(); +foreach (var dispatch in plan.Handlers) +{ + await dispatch(scope.ServiceProvider, message, envelope, ct); +} +``` + +When `plan.IsPartitioned`, serialize same-key deliveries through `plan.Partition` so per-key order +is preserved (the RabbitMQ transport lanes deliveries through a `Partitioner`). The +`MessageEnvelope` also carries metadata for the bare-JSON fallback — resolve unknown types via +`provider.GetMessageType(urn)` / `registry.GetPlan(typeName)`. + +### 4. RPC replies + +A request consumer replies with a `MessageEnvelope`: the `TResponse` payload at the response +type's URN on success, or an `RpcFault` at `RpcFault.UrnUri` on failure. Keeping the envelope and +`RpcFault` shapes identical across transports means Vulthil clients interoperate without a +transport-specific reply contract: + +```csharp +var fault = new RpcFault +{ + Message = ex.Message, + ExceptionType = ex.GetType().FullName!, + StackTrace = ex.StackTrace, + FaultedAt = DateTimeOffset.UtcNow, +}; +var reply = new MessageEnvelope +{ + MessageType = RpcFault.UrnUri, + Message = JsonSerializer.SerializeToElement(fault, provider.JsonSerializerOptions), + RequestId = request.RequestId, +}; +``` + ## Testing Messaging `Vulthil.Messaging.TestHarness` provides an in-memory transport that captures diff --git a/src/Vulthil.Messaging/README.md b/src/Vulthil.Messaging/README.md index c481727..0ce1815 100644 --- a/src/Vulthil.Messaging/README.md +++ b/src/Vulthil.Messaging/README.md @@ -2,6 +2,10 @@ Messaging composition APIs for configuring consumers, queues, and hosted processing. +The `Vulthil.Messaging.Transport` namespace is a build-your-own-transport SDK for implementing +custom brokers against the same envelope, execution-plan, and context primitives the RabbitMQ +transport uses. + ## Install `dotnet add package Vulthil.Messaging` From c7b539b812dfb4c0e149ddd546145309770f6332 Mon Sep 17 00:00:00 2001 From: Vulthil Date: Fri, 5 Jun 2026 00:10:00 +0200 Subject: [PATCH 32/42] feat(messaging): bounded publish channel pool with RabbitMq transport options --- .../packages/vulthil-messaging-rabbitmq.md | 24 +++ .../MessagingConfiguratorExtensions.cs | 17 +- .../PublicAPI.Unshipped.txt | 7 +- .../Publishing/RabbitMqChannelPool.cs | 89 +++++++++++ .../Publishing/RabbitMqPublisher.cs | 146 +++++------------- .../RabbitMqTransportOptions.cs | 24 +++ .../Vulthil.Messaging.RabbitMq.csproj | 1 + .../RabbitMqChannelPoolTests.cs | 111 +++++++++++++ .../RabbitMqPublisherExtendedTests.cs | 4 +- .../RabbitMqPublisherTests.cs | 4 +- 10 files changed, 317 insertions(+), 110 deletions(-) create mode 100644 src/Vulthil.Messaging.RabbitMq/Publishing/RabbitMqChannelPool.cs create mode 100644 src/Vulthil.Messaging.RabbitMq/RabbitMqTransportOptions.cs create mode 100644 tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqChannelPoolTests.cs diff --git a/docs/articles/packages/vulthil-messaging-rabbitmq.md b/docs/articles/packages/vulthil-messaging-rabbitmq.md index 5ae06b6..d2a9aa7 100644 --- a/docs/articles/packages/vulthil-messaging-rabbitmq.md +++ b/docs/articles/packages/vulthil-messaging-rabbitmq.md @@ -52,6 +52,30 @@ Queue settings can be bound from `appsettings.json` under `Messaging:Queues:{nam } ``` +### Transport options + +Transport tuning binds from the `Messaging:RabbitMq` section and can be overridden in +code (code takes precedence). The publisher pools channels for concurrent publishing — +each leased channel awaits its own publisher confirm — so `PublishChannelPoolSize` +(default `10`) bounds how many publishes can be in flight at once. + +```json +{ + "Messaging": { + "RabbitMq": { + "PublishChannelPoolSize": 32 + } + } +} +``` + +```csharp +messaging.UseRabbitMq(configureTransport: options => +{ + options.PublishChannelPoolSize = 32; +}); +``` + ### Tracing and health checks `UseRabbitMq` registers an OpenTelemetry `ActivitySource` diff --git a/src/Vulthil.Messaging.RabbitMq/MessagingConfiguratorExtensions.cs b/src/Vulthil.Messaging.RabbitMq/MessagingConfiguratorExtensions.cs index 70d2a34..aa7cce7 100644 --- a/src/Vulthil.Messaging.RabbitMq/MessagingConfiguratorExtensions.cs +++ b/src/Vulthil.Messaging.RabbitMq/MessagingConfiguratorExtensions.cs @@ -28,12 +28,17 @@ public static class MessagingConfiguratorExtensions /// activity source, so the entire RabbitMQ tracing pipeline (Aspire client + Vulthil transport) can be toggled with a single flag. /// /// Optional callback for tuning the underlying . + /// + /// Optional callback for tuning the Vulthil RabbitMQ transport (e.g. the publish channel pool size). Options are + /// first bound from the Messaging:RabbitMq configuration section; this callback then runs and takes precedence. + /// /// The same configurator, for chaining. public static IMessagingConfigurator UseRabbitMq( this IMessagingConfigurator configurator, string connectionStringKey = "rabbitMq", Action? configureSettings = null, - Action? configureConnectionFactory = null) + Action? configureConnectionFactory = null, + Action? configureTransport = null) { var tracingEnabled = true; var healthChecksEnabled = true; @@ -50,10 +55,20 @@ public static IMessagingConfigurator UseRabbitMq( var services = configurator.HostApplicationBuilder.Services; + var transportOptionsBuilder = services.AddOptions() + .Bind(configurator.HostApplicationBuilder.Configuration.GetSection(RabbitMqTransportOptions.SectionName)); + if (configureTransport is not null) + { + transportOptionsBuilder.Configure(configureTransport); + } + transportOptionsBuilder.ValidateDataAnnotations().ValidateOnStart(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(sp => sp.GetRequiredService()); diff --git a/src/Vulthil.Messaging.RabbitMq/PublicAPI.Unshipped.txt b/src/Vulthil.Messaging.RabbitMq/PublicAPI.Unshipped.txt index 614f591..fb477f1 100644 --- a/src/Vulthil.Messaging.RabbitMq/PublicAPI.Unshipped.txt +++ b/src/Vulthil.Messaging.RabbitMq/PublicAPI.Unshipped.txt @@ -1,7 +1,12 @@ #nullable enable +const Vulthil.Messaging.RabbitMq.RabbitMqTransportOptions.SectionName = "Messaging:RabbitMq" -> string! const Vulthil.Messaging.RabbitMq.Telemetry.MessagingInstrumentation.ActivitySourceName = "Vulthil.Messaging.RabbitMq" -> string! -static Vulthil.Messaging.RabbitMq.MessagingConfiguratorExtensions.UseRabbitMq(this Vulthil.Messaging.IMessagingConfigurator! configurator, string! connectionStringKey = "rabbitMq", System.Action? configureSettings = null, System.Action? configureConnectionFactory = null) -> Vulthil.Messaging.IMessagingConfigurator! +static Vulthil.Messaging.RabbitMq.MessagingConfiguratorExtensions.UseRabbitMq(this Vulthil.Messaging.IMessagingConfigurator! configurator, string! connectionStringKey = "rabbitMq", System.Action? configureSettings = null, System.Action? configureConnectionFactory = null, System.Action? configureTransport = null) -> Vulthil.Messaging.IMessagingConfigurator! static Vulthil.Messaging.RabbitMq.Telemetry.TracerProviderBuilderExtensions.AddVulthilMessagingInstrumentation(this OpenTelemetry.Trace.TracerProviderBuilder! builder) -> OpenTelemetry.Trace.TracerProviderBuilder! Vulthil.Messaging.RabbitMq.MessagingConfiguratorExtensions +Vulthil.Messaging.RabbitMq.RabbitMqTransportOptions +Vulthil.Messaging.RabbitMq.RabbitMqTransportOptions.PublishChannelPoolSize.get -> int +Vulthil.Messaging.RabbitMq.RabbitMqTransportOptions.PublishChannelPoolSize.set -> void +Vulthil.Messaging.RabbitMq.RabbitMqTransportOptions.RabbitMqTransportOptions() -> void Vulthil.Messaging.RabbitMq.Telemetry.MessagingInstrumentation Vulthil.Messaging.RabbitMq.Telemetry.TracerProviderBuilderExtensions diff --git a/src/Vulthil.Messaging.RabbitMq/Publishing/RabbitMqChannelPool.cs b/src/Vulthil.Messaging.RabbitMq/Publishing/RabbitMqChannelPool.cs new file mode 100644 index 0000000..c4815c7 --- /dev/null +++ b/src/Vulthil.Messaging.RabbitMq/Publishing/RabbitMqChannelPool.cs @@ -0,0 +1,89 @@ +using System.Collections.Concurrent; +using Microsoft.Extensions.Options; +using RabbitMQ.Client; + +namespace Vulthil.Messaging.RabbitMq.Publishing; + +/// +/// A bounded pool of publisher instances. Each lease hands out a channel that is used +/// non-concurrently (a RabbitMQ.Client v7 requirement); concurrency comes from leasing distinct channels, capped +/// at the configured size. Channels are created lazily with publisher confirms enabled and reused on return; a +/// channel that faults during publish is discarded rather than returned, so a poisoned channel is never reused. +/// +internal sealed class RabbitMqChannelPool : IAsyncDisposable +{ + private readonly IConnection _connection; + private readonly IOptions _options; + private readonly Lazy _lazySemaphore; + private readonly ConcurrentQueue _idle = new(); + private SemaphoreSlim Capacity => _lazySemaphore.Value; + private bool _disposed; + + public RabbitMqChannelPool(IConnection connection, IOptions options) + { + _connection = connection; + _options = options; + _lazySemaphore = new Lazy(() => new SemaphoreSlim(_options.Value.PublishChannelPoolSize, _options.Value.PublishChannelPoolSize)); + } + + /// + /// Leases a channel, waiting if the pool is at capacity. The caller must return it via + /// (on success) or (if the channel faulted) so the capacity slot is released. + /// + public async ValueTask LeaseAsync(CancellationToken cancellationToken) + { + await Capacity.WaitAsync(cancellationToken); + try + { + if (_idle.TryDequeue(out var pooled)) + { + return pooled; + } + + return await _connection.CreateChannelAsync( + new CreateChannelOptions( + publisherConfirmationsEnabled: true, + publisherConfirmationTrackingEnabled: true, + consumerDispatchConcurrency: 1), + cancellationToken); + } + catch + { + Capacity.Release(); + throw; + } + } + + /// Returns a healthy leased channel to the pool for reuse and frees its capacity slot. + public void Return(IChannel channel) + { + _idle.Enqueue(channel); + Capacity.Release(); + } + + /// Disposes a faulted leased channel instead of reusing it, freeing its capacity slot. + public async ValueTask DiscardAsync(IChannel channel) + { + await channel.DisposeAsync(); + Capacity.Release(); + } + + public async ValueTask DisposeAsync() + { + if (_disposed) + { + return; + } + _disposed = true; + + while (_idle.TryDequeue(out var channel)) + { + await channel.DisposeAsync(); + } + + if (_lazySemaphore.IsValueCreated) + { + Capacity.Dispose(); + } + } +} diff --git a/src/Vulthil.Messaging.RabbitMq/Publishing/RabbitMqPublisher.cs b/src/Vulthil.Messaging.RabbitMq/Publishing/RabbitMqPublisher.cs index d013cc8..28453f6 100644 --- a/src/Vulthil.Messaging.RabbitMq/Publishing/RabbitMqPublisher.cs +++ b/src/Vulthil.Messaging.RabbitMq/Publishing/RabbitMqPublisher.cs @@ -10,24 +10,21 @@ namespace Vulthil.Messaging.RabbitMq.Publishing; -internal sealed class RabbitMqPublisher : IPublisher, IInternalPublisher, IAsyncDisposable +internal sealed class RabbitMqPublisher : IPublisher, IInternalPublisher { - private readonly IConnection _rabbitMqConnection; private readonly IMessageConfigurationProvider _messageConfigurationProvider; private readonly ILogger _logger; - - private readonly SemaphoreSlim _channelSemaphore = new(1, 1); - private IChannel? _channel; + private readonly RabbitMqChannelPool _channelPool; private readonly ConcurrentDictionary _knownExchanges = new(); public RabbitMqPublisher( - IConnection rabbitMqConnection, IMessageConfigurationProvider messageConfigurationProvider, + RabbitMqChannelPool channelPool, ILogger logger) { - _rabbitMqConnection = rabbitMqConnection; _messageConfigurationProvider = messageConfigurationProvider; + _channelPool = channelPool; _logger = logger; } @@ -40,12 +37,24 @@ public async Task InternalPublishAsync( { var exchange = messageConfiguration.Exchange; - await EnsureChannelAsync(cancellationToken); - await EnsureExchangeTopologyAsync(exchange, messageConfiguration, cancellationToken); + // Publisher confirmations (with tracking) make the awaited BasicPublishAsync wait for the broker ack and + // throw on a nack or unroutable-mandatory return. The channel pool bounds concurrent publishes; each + // leased channel is used non-concurrently and reused on return, replacing the single-channel bottleneck. + var channel = await _channelPool.LeaseAsync(cancellationToken); + try + { + await EnsureExchangeTopologyAsync(channel, exchange, messageConfiguration, cancellationToken); - // Publish is pub/sub over a fanout/topic exchange: zero bound subscribers is normal, so the message - // is not mandatory. Broker confirms still apply (a nack throws), guarding against broker-side loss. - await BasicPublishAsync(exchange, routingKey, props, body, mandatory: false, cancellationToken); + // Publish is pub/sub over a fanout/topic exchange: zero bound subscribers is normal, so the message + // is not mandatory. Broker confirms still apply (a nack throws), guarding against broker-side loss. + await channel.BasicPublishAsync(exchange, routingKey, mandatory: false, props, body, cancellationToken); + _channelPool.Return(channel); + } + catch + { + await _channelPool.DiscardAsync(channel); + throw; + } } public async Task InternalSendAsync( @@ -58,33 +67,16 @@ public async Task InternalSendAsync( // The destination queue is owned by the receiving service, so we do not declare it here. // A send is point-to-point, so a missing destination queue is a real error: publish mandatory so // the broker returns an unroutable message and the awaited confirm throws PublishReturnException. - await EnsureChannelAsync(cancellationToken); - await BasicPublishAsync(exchange: string.Empty, routingKey: queueName, props, body, mandatory: true, cancellationToken); - } - - private async Task BasicPublishAsync( - string exchange, - string routingKey, - BasicProperties props, - byte[] body, - bool mandatory, - CancellationToken cancellationToken) - { - await _channelSemaphore.WaitAsync(cancellationToken); - + var channel = await _channelPool.LeaseAsync(cancellationToken); try { - await _channel!.BasicPublishAsync( - exchange: exchange, - routingKey: routingKey, - mandatory: mandatory, - basicProperties: props, - body: body, - cancellationToken: cancellationToken); + await channel.BasicPublishAsync(exchange: string.Empty, routingKey: queueName, mandatory: true, props, body, cancellationToken); + _channelPool.Return(channel); } - finally + catch { - _channelSemaphore.Release(); + await _channelPool.DiscardAsync(channel); + throw; } } @@ -167,82 +159,24 @@ public async Task PublishAsync( } } - private async ValueTask EnsureExchangeTopologyAsync(string exchange, MessageConfiguration messageConfiguration, CancellationToken cancellationToken) + private async ValueTask EnsureExchangeTopologyAsync(IChannel channel, string exchange, MessageConfiguration messageConfiguration, CancellationToken cancellationToken) { if (_knownExchanges.ContainsKey(exchange)) { return; } - await _channelSemaphore.WaitAsync(cancellationToken); - try - { - if (_knownExchanges.ContainsKey(exchange)) - { - return; - } - - await _channel!.ExchangeDeclareAsync( - exchange: exchange, - type: messageConfiguration.ExchangeType.ToRabbitExchangeType(), - durable: messageConfiguration.Durable, - autoDelete: messageConfiguration.AutoDelete, - arguments: messageConfiguration.Arguments, - cancellationToken: cancellationToken); - - _knownExchanges.TryAdd(exchange, true); - MessagingLog.ExchangeDeclared(_logger, exchange, messageConfiguration.ExchangeType); - } - finally - { - _channelSemaphore.Release(); - } - } - - private async ValueTask EnsureChannelAsync(CancellationToken cancellationToken) - { - if (_channel is not null) - { - return; - } - - await _channelSemaphore.WaitAsync(cancellationToken); - - try - { -#pragma warning disable CA1508 // Avoid dead conditional code - if (_channel is not null) - { - return; - } -#pragma warning restore CA1508 - - // Publisher confirmations (with tracking) make the awaited BasicPublishAsync wait for the broker - // ack and throw on a nack or unroutable-mandatory return, so a publish that the broker never - // accepted no longer reports success. This serializes publishing through the single channel until - // a channel pool is introduced; tune throughput there. - _channel = await _rabbitMqConnection.CreateChannelAsync( - new CreateChannelOptions( - publisherConfirmationsEnabled: true, - publisherConfirmationTrackingEnabled: true, - consumerDispatchConcurrency: 1), - cancellationToken); - } - finally - { - _channelSemaphore.Release(); - } - } - - public async ValueTask DisposeAsync() - { - if (_channel is not null) - { - await _channel.DisposeAsync(); - } - - _channelSemaphore.Dispose(); - - GC.SuppressFinalize(this); + // ExchangeDeclare is idempotent, so a concurrent first-publish burst that declares the same exchange on + // several pooled channels is harmless; the cache then short-circuits subsequent publishes. + await channel.ExchangeDeclareAsync( + exchange: exchange, + type: messageConfiguration.ExchangeType.ToRabbitExchangeType(), + durable: messageConfiguration.Durable, + autoDelete: messageConfiguration.AutoDelete, + arguments: messageConfiguration.Arguments, + cancellationToken: cancellationToken); + + _knownExchanges.TryAdd(exchange, true); + MessagingLog.ExchangeDeclared(_logger, exchange, messageConfiguration.ExchangeType); } } diff --git a/src/Vulthil.Messaging.RabbitMq/RabbitMqTransportOptions.cs b/src/Vulthil.Messaging.RabbitMq/RabbitMqTransportOptions.cs new file mode 100644 index 0000000..2398aa6 --- /dev/null +++ b/src/Vulthil.Messaging.RabbitMq/RabbitMqTransportOptions.cs @@ -0,0 +1,24 @@ +using System.ComponentModel.DataAnnotations; + +namespace Vulthil.Messaging.RabbitMq; + +/// +/// RabbitMQ transport tuning options. Bound from the Messaging:RabbitMq configuration section and +/// optionally overridden in code via the configureTransport callback on +/// (code takes precedence over configuration). +/// +public sealed class RabbitMqTransportOptions +{ + private const int DefaultPublishChannelPoolSize = 10; + + /// The configuration section these options bind from. + public const string SectionName = "Messaging:RabbitMq"; + + /// + /// Gets or sets the maximum number of channels the publisher pools for concurrent publishing. Each channel + /// awaits its own publisher confirm, so a larger pool allows more in-flight publishes at the cost of more + /// broker channels. Must be at least 1. Default is 10. + /// + [Range(1, int.MaxValue)] + public int PublishChannelPoolSize { get; set; } = DefaultPublishChannelPoolSize; +} diff --git a/src/Vulthil.Messaging.RabbitMq/Vulthil.Messaging.RabbitMq.csproj b/src/Vulthil.Messaging.RabbitMq/Vulthil.Messaging.RabbitMq.csproj index b8d1072..1f14aec 100644 --- a/src/Vulthil.Messaging.RabbitMq/Vulthil.Messaging.RabbitMq.csproj +++ b/src/Vulthil.Messaging.RabbitMq/Vulthil.Messaging.RabbitMq.csproj @@ -8,6 +8,7 @@ + diff --git a/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqChannelPoolTests.cs b/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqChannelPoolTests.cs new file mode 100644 index 0000000..ecb64c6 --- /dev/null +++ b/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqChannelPoolTests.cs @@ -0,0 +1,111 @@ +using Microsoft.Extensions.Options; +using RabbitMQ.Client; +using Vulthil.Messaging.RabbitMq.Publishing; +using Vulthil.xUnit; + +namespace Vulthil.Messaging.RabbitMq.Tests; + +public sealed class RabbitMqChannelPoolTests : BaseUnitTestCase +{ + private readonly Mock _channelMock; + private readonly Mock _connectionMock; + + private readonly Lazy _lazyTarget; + private RabbitMqChannelPool Target => _lazyTarget.Value; + + public RabbitMqChannelPoolTests() + { + _channelMock = GetMock(); + _connectionMock = GetMock(); + _connectionMock + .Setup(c => c.CreateChannelAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(_channelMock.Object); + Use(Options.Create(new RabbitMqTransportOptions { PublishChannelPoolSize = 10 })); + + _lazyTarget = new(CreateInstance); + } + + protected override ValueTask Dispose() => _lazyTarget.IsValueCreated ? Target.DisposeAsync() : base.Dispose(); + + + [Fact] + public async Task LeaseCreatesAChannelFromTheConnection() + { + // Act + var leased = await Target.LeaseAsync(CancellationToken); + + // Assert + leased.ShouldBeSameAs(_channelMock.Object); + _connectionMock.Verify(c => c.CreateChannelAsync(It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task ReturnedChannelIsReusedRatherThanRecreated() + { + // Arrange + Use(Options.Create(new RabbitMqTransportOptions { PublishChannelPoolSize = 2 })); + + // Act + var first = await Target.LeaseAsync(CancellationToken); + Target.Return(first); + var second = await Target.LeaseAsync(CancellationToken); + + // Assert + second.ShouldBeSameAs(first); + _connectionMock.Verify(c => c.CreateChannelAsync(It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task LeaseBeyondCapacityWaitsUntilAChannelIsReturned() + { + // Arrange + Use(Options.Create(new RabbitMqTransportOptions { PublishChannelPoolSize = 1 })); + var first = await Target.LeaseAsync(CancellationToken); + + // Act — a second lease cannot complete while the single slot is held. + var secondLease = Target.LeaseAsync(CancellationToken).AsTask(); + secondLease.IsCompleted.ShouldBeFalse(); + + Target.Return(first); + + // Assert — returning the channel frees the slot and unblocks the waiter. + var second = await secondLease.WaitAsync(TimeSpan.FromSeconds(5), CancellationToken); + second.ShouldBeSameAs(first); + } + + [Fact] + public async Task DiscardDisposesTheChannelAndFreesTheSlot() + { + // Arrange + Use(Options.Create(new RabbitMqTransportOptions { PublishChannelPoolSize = 1 })); + var replacement = new Mock().Object; + var channels = new Queue([_channelMock.Object, replacement]); + _connectionMock + .Setup(c => c.CreateChannelAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(channels.Dequeue); + + // Act + var leased = await Target.LeaseAsync(CancellationToken); + await Target.DiscardAsync(leased); + var next = await Target.LeaseAsync(CancellationToken); + + // Assert + _channelMock.Verify(c => c.DisposeAsync(), Times.Once); + next.ShouldBeSameAs(replacement); + _connectionMock.Verify(c => c.CreateChannelAsync(It.IsAny(), It.IsAny()), Times.Exactly(2)); + } + + [Fact] + public async Task DisposeAsyncDisposesIdleChannels() + { + // Arrange + Use(Options.Create(new RabbitMqTransportOptions { PublishChannelPoolSize = 1 })); + Target.Return(await Target.LeaseAsync(CancellationToken)); + + // Act + await Target.DisposeAsync(); + + // Assert + _channelMock.Verify(c => c.DisposeAsync(), Times.Once); + } +} diff --git a/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqPublisherExtendedTests.cs b/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqPublisherExtendedTests.cs index a0faa3d..ff7b8f7 100644 --- a/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqPublisherExtendedTests.cs +++ b/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqPublisherExtendedTests.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using RabbitMQ.Client; using Vulthil.Messaging.RabbitMq.Publishing; using Vulthil.xUnit; @@ -32,7 +33,8 @@ public RabbitMqPublisherExtendedTests() .Returns(t => new MessageConfiguration(t.FullName!)); Use(logger); - Use(connectionMock.Object); + Use(Options.Create(new RabbitMqTransportOptions { PublishChannelPoolSize = 1 })); + UseReal(); _lazyTarget = new(CreateInstance); } diff --git a/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqPublisherTests.cs b/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqPublisherTests.cs index 79d07ac..fe64541 100644 --- a/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqPublisherTests.cs +++ b/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqPublisherTests.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using RabbitMQ.Client; using Vulthil.Messaging.RabbitMq.Publishing; using Vulthil.xUnit; @@ -31,7 +32,8 @@ public RabbitMqPublisherTests() .Returns(t => new MessageConfiguration(t.FullName!)); Use(logger); - Use(connectionMock.Object); + Use(Options.Create(new RabbitMqTransportOptions { PublishChannelPoolSize = 1 })); + UseReal(); _lazyTarget = new(CreateInstance); } From ee26cb4c90a78afaa3af6d18df7d6b8a4965be58 Mon Sep 17 00:00:00 2001 From: Vulthil Date: Fri, 5 Jun 2026 00:33:36 +0200 Subject: [PATCH 33/42] test: add BaseUnitTestCase teardown hook; adopt Target and ctor mock-field patterns across tests --- src/Vulthil.xUnit/BaseUnitTestCase.cs | 10 ++++-- src/Vulthil.xUnit/PublicAPI.Unshipped.txt | 1 + .../MessageContextSendTests.cs | 35 +++++++++---------- .../MessageTypeCacheTests.cs | 14 ++++---- .../RabbitMqBusTopologyTests.cs | 14 +++++--- .../RabbitMqRequesterTests.cs | 6 ++-- .../MessageExecutionRegistryTests.cs | 7 ++-- .../ValidationPipelineBehaviorTests.cs | 25 +++++++------ 8 files changed, 67 insertions(+), 45 deletions(-) diff --git a/src/Vulthil.xUnit/BaseUnitTestCase.cs b/src/Vulthil.xUnit/BaseUnitTestCase.cs index 9fb50b5..51f38b3 100644 --- a/src/Vulthil.xUnit/BaseUnitTestCase.cs +++ b/src/Vulthil.xUnit/BaseUnitTestCase.cs @@ -15,12 +15,18 @@ public abstract class BaseUnitTestCase : IAsyncLifetime protected AutoMocker AutoMocker { get; } = new(); /// - public ValueTask DisposeAsync() + public async ValueTask DisposeAsync() { + await Dispose(); GC.SuppressFinalize(this); - return ValueTask.CompletedTask; } + /// + /// Override to perform custom async cleanup after each test. + /// + /// A task representing the cleanup work. + protected virtual ValueTask Dispose() => ValueTask.CompletedTask; + /// public ValueTask InitializeAsync() => Initialize(); diff --git a/src/Vulthil.xUnit/PublicAPI.Unshipped.txt b/src/Vulthil.xUnit/PublicAPI.Unshipped.txt index fda82a4..6d19dcc 100644 --- a/src/Vulthil.xUnit/PublicAPI.Unshipped.txt +++ b/src/Vulthil.xUnit/PublicAPI.Unshipped.txt @@ -10,6 +10,7 @@ static Vulthil.xUnit.BaseUnitTestCase.CancellationToken.get -> System.Threading. virtual Vulthil.xUnit.BaseIntegrationTestCase.DisposeAsync() -> System.Threading.Tasks.ValueTask virtual Vulthil.xUnit.BaseIntegrationTestCase.Initialize() -> System.Threading.Tasks.ValueTask virtual Vulthil.xUnit.BaseUnitTestCase.CreateInstance() -> TTarget! +virtual Vulthil.xUnit.BaseUnitTestCase.Dispose() -> System.Threading.Tasks.ValueTask virtual Vulthil.xUnit.BaseUnitTestCase.GetMock() -> Moq.Mock! virtual Vulthil.xUnit.BaseUnitTestCase.Initialize() -> System.Threading.Tasks.ValueTask virtual Vulthil.xUnit.BaseUnitTestCase.Use(TService service) -> void diff --git a/tests/Vulthil.Messaging.RabbitMq.Tests/MessageContextSendTests.cs b/tests/Vulthil.Messaging.RabbitMq.Tests/MessageContextSendTests.cs index 112a6c6..3321436 100644 --- a/tests/Vulthil.Messaging.RabbitMq.Tests/MessageContextSendTests.cs +++ b/tests/Vulthil.Messaging.RabbitMq.Tests/MessageContextSendTests.cs @@ -14,6 +14,18 @@ public sealed class MessageContextSendTests : BaseUnitTestCase { private sealed record TestMessage(string Content); + private readonly Mock _endpointMock; + private readonly Mock _providerMock; + + public MessageContextSendTests() + { + _endpointMock = new Mock(); + _providerMock = new Mock(); + _providerMock + .Setup(p => p.GetSendEndpointAsync(It.IsAny(), It.IsAny())) + .Returns((_, _) => ValueTask.FromResult(_endpointMock.Object)); + } + /// /// Verifies that CorrelationId, ConversationId, and InitiatorId from the incoming context are propagated to the outgoing send. /// @@ -21,9 +33,8 @@ private sealed record TestMessage(string Content); public async Task SendAsyncShouldPropagateCorrelationMetadata() { // Arrange - var endpointMock = new Mock(); var capturedPublishContext = new PublishContext(); - endpointMock + _endpointMock .Setup(e => e.SendAsync( It.IsAny(), It.IsAny?>(), @@ -37,13 +48,8 @@ public async Task SendAsyncShouldPropagateCorrelationMetadata() } }); - var providerMock = new Mock(); - providerMock - .Setup(p => p.GetSendEndpointAsync(It.IsAny(), It.IsAny())) - .Returns((uri, _) => ValueTask.FromResult(endpointMock.Object)); - var context = CreateTypedContext( - providerMock.Object, + _providerMock.Object, correlationId: "corr-1", conversationId: "conv-1", messageId: "msg-1"); @@ -64,9 +70,8 @@ public async Task SendAsyncShouldPropagateCorrelationMetadata() public async Task SendAsyncShouldLetExplicitConfigureOverrideAutoPropagation() { // Arrange - var endpointMock = new Mock(); var capturedPublishContext = new PublishContext(); - endpointMock + _endpointMock .Setup(e => e.SendAsync( It.IsAny(), It.IsAny?>(), @@ -80,12 +85,7 @@ public async Task SendAsyncShouldLetExplicitConfigureOverrideAutoPropagation() } }); - var providerMock = new Mock(); - providerMock - .Setup(p => p.GetSendEndpointAsync(It.IsAny(), It.IsAny())) - .Returns((_, _) => ValueTask.FromResult(endpointMock.Object)); - - var context = CreateTypedContext(providerMock.Object, correlationId: "auto-corr"); + var context = CreateTypedContext(_providerMock.Object, correlationId: "auto-corr"); // Act await context.SendAsync( @@ -108,8 +108,7 @@ await context.SendAsync( public async Task SendAsyncWithNullDestinationThrows() { // Arrange - var providerMock = new Mock(); - var context = CreateTypedContext(providerMock.Object); + var context = CreateTypedContext(_providerMock.Object); // Act & Assert await Assert.ThrowsAsync( diff --git a/tests/Vulthil.Messaging.RabbitMq.Tests/MessageTypeCacheTests.cs b/tests/Vulthil.Messaging.RabbitMq.Tests/MessageTypeCacheTests.cs index cba83e5..8688d82 100644 --- a/tests/Vulthil.Messaging.RabbitMq.Tests/MessageTypeCacheTests.cs +++ b/tests/Vulthil.Messaging.RabbitMq.Tests/MessageTypeCacheTests.cs @@ -13,6 +13,7 @@ public sealed class MessageTypeCacheTests : BaseUnitTestCase { private readonly Lazy _lazyTarget; private readonly IServiceProvider _serviceProvider; + private readonly Mock _channelMock; private MessageTypeCache Target => _lazyTarget.Value; @@ -23,6 +24,7 @@ public MessageTypeCacheTests() Use>>([]); Use>>([]); + _channelMock = GetMock(); _serviceProvider = AutoMocker; } @@ -152,7 +154,7 @@ public async Task CompiledHandlerShouldCallConsumerWithCorrectMessage() var testMessage = new TestMessage("Hello, World!"); // Act - await handler.DispatchAsync(_serviceProvider, testMessage, CreateDeliverEventArgs(), null, Mock.Of(), CancellationToken.None); + await handler.DispatchAsync(_serviceProvider, testMessage, CreateDeliverEventArgs(), null, _channelMock.Object, CancellationToken.None); // Assert consumerInstance.ReceivedMessages.ShouldHaveSingleItem(); @@ -183,11 +185,10 @@ public async Task CompiledRpcHandlerShouldCallConsumerAndPublishResponse() var testRequest = new TestRequest("Find users"); var deliveryArgs = CreateDeliverEventArgs(replyTo: "reply.queue", correlationId: "corr-1"); - var channel = GetMock(); ReadOnlyMemory publishedBody = default; BasicProperties? publishedProperties = null; - channel.Setup(x => x.BasicPublishAsync( + _channelMock.Setup(x => x.BasicPublishAsync( It.IsAny(), It.IsAny(), It.IsAny(), @@ -202,7 +203,7 @@ public async Task CompiledRpcHandlerShouldCallConsumerAndPublishResponse() .Returns(ValueTask.CompletedTask); // Act - await handler.DispatchAsync(_serviceProvider, testRequest, deliveryArgs, null, channel.Object, CancellationToken.None); + await handler.DispatchAsync(_serviceProvider, testRequest, deliveryArgs, null, _channelMock.Object, CancellationToken.None); // Assert consumerInstance.ReceivedRequests.ShouldHaveSingleItem(); @@ -240,10 +241,9 @@ public async Task CompiledRpcHandlerShouldPublishFailureWhenConsumerThrows() var plan = Target.GetPlan(new MessageType(typeof(TestRequest)).Name); var handler = plan!.Handlers.Single(h => h.Kind == HandlerKind.RequestConsumer); - var channel = GetMock(); ReadOnlyMemory publishedBody = default; - channel.Setup(x => x.BasicPublishAsync( + _channelMock.Setup(x => x.BasicPublishAsync( It.IsAny(), It.IsAny(), It.IsAny(), @@ -262,7 +262,7 @@ await handler.DispatchAsync( new TestRequest("throw"), CreateDeliverEventArgs(replyTo: "reply.queue"), null, - channel.Object, + _channelMock.Object, CancellationToken.None); // Assert diff --git a/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqBusTopologyTests.cs b/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqBusTopologyTests.cs index 7174929..cd742cd 100644 --- a/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqBusTopologyTests.cs +++ b/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqBusTopologyTests.cs @@ -14,6 +14,9 @@ public sealed class RabbitMqBusTopologyTests : BaseUnitTestCase private readonly Dictionary> _declaredQueues = new(StringComparer.Ordinal); + private readonly Lazy _lazyTarget; + private RabbitMqBus Target => _lazyTarget.Value; + public RabbitMqBusTopologyTests() { var channel = GetMock(); @@ -37,13 +40,16 @@ public RabbitMqBusTopologyTests() Use(new RabbitMqBusStartupStatus()); Use(NullLoggerFactory.Instance); Use>(NullLogger.Instance); + + _lazyTarget = new(CreateInstance); } - private async Task DeclareTopologyAsync(IMessageConfigurationProvider provider) + protected override ValueTask Dispose() => _lazyTarget.IsValueCreated ? Target.DisposeAsync() : base.Dispose(); + + private Task DeclareTopologyAsync(IMessageConfigurationProvider provider) { - Use(provider); - await using var bus = CreateInstance(); - await bus.StartAsync(CancellationToken); + Use(provider); + return Target.StartAsync(CancellationToken); } private static IMessageConfigurationProvider ProviderConsumingOrderedEvents( diff --git a/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqRequesterTests.cs b/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqRequesterTests.cs index 1ba370f..9c5facb 100644 --- a/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqRequesterTests.cs +++ b/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqRequesterTests.cs @@ -11,6 +11,7 @@ namespace Vulthil.Messaging.RabbitMq.Tests; public sealed class RabbitMqRequesterTests : BaseUnitTestCase { private readonly Lazy _lazyTarget; + private readonly Mock _publisherMock; private RabbitMqRequester Target => _lazyTarget.Value; @@ -36,7 +37,8 @@ public RabbitMqRequesterTests() Use(CreateInstance()); - GetMock() + _publisherMock = GetMock(); + _publisherMock .Setup(p => p.InternalPublishAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); @@ -74,7 +76,7 @@ public async Task RequestAsyncCorrelatesOnAFreshRequestIdDistinctFromBusinessCor const string businessCorrelationId = "order-42"; BasicProperties? capturedProps = null; byte[]? capturedBody = null; - GetMock() + _publisherMock .Setup(p => p.InternalPublishAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Callback((byte[] body, BasicProperties props, string _, MessageConfiguration _, CancellationToken _) => diff --git a/tests/Vulthil.Messaging.Tests/Transport/MessageExecutionRegistryTests.cs b/tests/Vulthil.Messaging.Tests/Transport/MessageExecutionRegistryTests.cs index d5a444a..fc15d98 100644 --- a/tests/Vulthil.Messaging.Tests/Transport/MessageExecutionRegistryTests.cs +++ b/tests/Vulthil.Messaging.Tests/Transport/MessageExecutionRegistryTests.cs @@ -20,9 +20,12 @@ public HandlerEntry ForRequestConsumer(Type consumerType, Type requ public sealed class MessageExecutionRegistryTests : BaseUnitTestCase> { + private readonly Mock _providerMock; + public MessageExecutionRegistryTests() { - GetMock() + _providerMock = GetMock(); + _providerMock .Setup(p => p.GetUrn(It.IsAny())) .Returns(t => new Uri($"urn:test:{t.FullName}")); Use>(new FakeHandlerFactory()); @@ -176,7 +179,7 @@ public void RegisterQueueAttachesThePartitionSpecForPartitionedTypes() { // Arrange var spec = new PartitionSpec(new Partitioner(4), (Func)(o => o.Id)); - GetMock() + _providerMock .Setup(p => p.GetPartition(typeof(OrderPlaced))) .Returns(spec); diff --git a/tests/Vulthil.SharedKernel.Application.Tests/Pipeline/ValidationPipelineBehaviorTests.cs b/tests/Vulthil.SharedKernel.Application.Tests/Pipeline/ValidationPipelineBehaviorTests.cs index e42b7e9..20b552d 100644 --- a/tests/Vulthil.SharedKernel.Application.Tests/Pipeline/ValidationPipelineBehaviorTests.cs +++ b/tests/Vulthil.SharedKernel.Application.Tests/Pipeline/ValidationPipelineBehaviorTests.cs @@ -10,17 +10,25 @@ namespace Vulthil.SharedKernel.Application.Tests.Pipeline; public sealed class ValidationPipelineBehaviorTests : BaseUnitTestCase { + private readonly Lazy> _lazyTarget; + private readonly Mock> _validatorMock; + private ValidationPipelineBehavior Target => _lazyTarget.Value; + + public ValidationPipelineBehaviorTests() + { + _validatorMock = GetMock>(); + Use>([_validatorMock.Object]); + _lazyTarget = new(CreateInstance>); + } + [Fact] public async Task WithValidRequestCallsNextDelegate() { // Arrange var request = new TestCommand { Name = "Test" }; var expectedResult = Result.Success(); - var mockValidator = GetMock>(); - Use>([mockValidator.Object]); - mockValidator.Setup(v => v.ValidateAsync(It.IsAny>(), It.IsAny())) + _validatorMock.Setup(v => v.ValidateAsync(It.IsAny>(), It.IsAny())) .ReturnsAsync(new ValidationResult(new List())); - var target = CreateInstance>(); var called = false; PipelineDelegate next = _ => { @@ -29,7 +37,7 @@ public async Task WithValidRequestCallsNextDelegate() }; // Act - var result = await target.HandleAsync(request, next, CancellationToken); + var result = await Target.HandleAsync(request, next, CancellationToken); // Assert Assert.True(called); @@ -41,9 +49,7 @@ public async Task WithInvalidRequestReturnsValidationError() { // Arrange var request = new TestCommand { Name = string.Empty }; - var mockValidator = GetMock>(); - Use>([mockValidator.Object]); - mockValidator.Setup(v => v.ValidateAsync(It.IsAny>(), It.IsAny())) + _validatorMock.Setup(v => v.ValidateAsync(It.IsAny>(), It.IsAny())) .ReturnsAsync(new ValidationResult(new List { new ValidationFailure("Name", "Name is required") @@ -51,11 +57,10 @@ public async Task WithInvalidRequestReturnsValidationError() ErrorCode = "Name" } })); - var target = CreateInstance>(); PipelineDelegate next = _ => Task.FromResult(Result.Success()); // Act - var result = await target.HandleAsync(request, next, CancellationToken); + var result = await Target.HandleAsync(request, next, CancellationToken); // Assert Assert.True(result.IsFailure); From 45451ae943727acf41c0efc1462f163432a4e1cb Mon Sep 17 00:00:00 2001 From: Vulthil Date: Fri, 5 Jun 2026 11:24:53 +0200 Subject: [PATCH 34/42] test(messaging): drive consume-filter and polymorphic dispatch tests through AutoMocker --- .../ConsumeFilterPipelineTests.cs | 76 +++++++------------ .../MessageTypeCacheTests.cs | 2 +- .../PolymorphicDispatchTests.cs | 18 ++--- 3 files changed, 36 insertions(+), 60 deletions(-) diff --git a/tests/Vulthil.Messaging.RabbitMq.Tests/ConsumeFilterPipelineTests.cs b/tests/Vulthil.Messaging.RabbitMq.Tests/ConsumeFilterPipelineTests.cs index fd46ff7..56a2345 100644 --- a/tests/Vulthil.Messaging.RabbitMq.Tests/ConsumeFilterPipelineTests.cs +++ b/tests/Vulthil.Messaging.RabbitMq.Tests/ConsumeFilterPipelineTests.cs @@ -1,9 +1,7 @@ using System.Text.Json; -using Microsoft.Extensions.DependencyInjection; using RabbitMQ.Client; using RabbitMQ.Client.Events; using Vulthil.Messaging.Abstractions.Consumers; -using Vulthil.Messaging.Abstractions.Publishers; using Vulthil.Messaging.Queues; using Vulthil.Messaging.RabbitMq.Consumers; using Vulthil.Messaging.Transport; @@ -14,11 +12,15 @@ namespace Vulthil.Messaging.RabbitMq.Tests; public sealed class ConsumeFilterPipelineTests : BaseUnitTestCase { private readonly Lazy _lazyTarget; + private readonly Mock _channelMock; private MessageTypeCache Target => _lazyTarget.Value; public ConsumeFilterPipelineTests() { - Use(TestProviders.Build()); + Use(TestProviders.Build()); + Use>>([]); + Use>>([]); + _channelMock = GetMock(); _lazyTarget = new Lazy(CreateInstance); } @@ -92,11 +94,7 @@ public async Task PipelineWithNoFiltersInvokesConsumerDirectly() { // Arrange var consumerInstance = new RecordingConsumer(); - var services = new ServiceCollection(); - services.AddScoped(_ => consumerInstance); - services.AddSingleton(Mock.Of()); - services.AddSingleton(Mock.Of()); - var serviceProvider = services.BuildServiceProvider(); + Use(consumerInstance); var queue = new QueueDefinition("TestQueue"); queue.AddConsumer(new ConsumerRegistration @@ -109,7 +107,7 @@ public async Task PipelineWithNoFiltersInvokesConsumerDirectly() var handler = Target.GetPlan(new MessageType(typeof(TestMessage)).Name)!.Handlers[0]; // Act - await handler.DispatchAsync(serviceProvider, new TestMessage("payload"), CreateDeliverEventArgs(), (MessageEnvelope?)null, Mock.Of(), CancellationToken.None); + await handler.DispatchAsync(AutoMocker, new TestMessage("payload"), CreateDeliverEventArgs(), (MessageEnvelope?)null, _channelMock.Object, CancellationToken.None); // Assert consumerInstance.Received.ShouldHaveSingleItem().Content.ShouldBe("payload"); @@ -121,14 +119,12 @@ public async Task PipelineComposesFiltersInRegistrationOrderOutermostFirst() // Arrange var trace = new List(); var consumerInstance = new RecordingConsumer(); - var services = new ServiceCollection(); - services.AddScoped(_ => consumerInstance); - services.AddSingleton(Mock.Of()); - services.AddSingleton(Mock.Of()); - // Order matters: First registered should be outermost. - services.AddScoped>(_ => new RecordingFilter(trace, "outer")); - services.AddScoped>(_ => new RecordingFilter(trace, "inner")); - var serviceProvider = services.BuildServiceProvider(); + Use(consumerInstance); + // Order matters: the first filter in the list is the outermost. + Use>>([ + new RecordingFilter(trace, "outer"), + new RecordingFilter(trace, "inner"), + ]); var queue = new QueueDefinition("TestQueue"); queue.AddConsumer(new ConsumerRegistration @@ -141,7 +137,7 @@ public async Task PipelineComposesFiltersInRegistrationOrderOutermostFirst() var handler = Target.GetPlan(new MessageType(typeof(TestMessage)).Name)!.Handlers[0]; // Act - await handler.DispatchAsync(serviceProvider, new TestMessage("payload"), CreateDeliverEventArgs(), (MessageEnvelope?)null, Mock.Of(), CancellationToken.None); + await handler.DispatchAsync(AutoMocker, new TestMessage("payload"), CreateDeliverEventArgs(), (MessageEnvelope?)null, _channelMock.Object, CancellationToken.None); // Assert trace.ShouldBe(["outer:before", "inner:before", "inner:after", "outer:after"]); @@ -154,13 +150,8 @@ public async Task FilterShortCircuitPreventsConsumerInvocation() // Arrange var trace = new List(); var consumerInstance = new RecordingConsumer(); - var services = new ServiceCollection(); - services.AddScoped(_ => consumerInstance); - services.AddSingleton(Mock.Of()); - services.AddSingleton(Mock.Of()); - services.AddScoped>(_ => - new RecordingFilter(trace, "gate") { ShortCircuit = true }); - var serviceProvider = services.BuildServiceProvider(); + Use(consumerInstance); + Use>>([new RecordingFilter(trace, "gate") { ShortCircuit = true }]); var queue = new QueueDefinition("TestQueue"); queue.AddConsumer(new ConsumerRegistration @@ -173,7 +164,7 @@ public async Task FilterShortCircuitPreventsConsumerInvocation() var handler = Target.GetPlan(new MessageType(typeof(TestMessage)).Name)!.Handlers[0]; // Act - await handler.DispatchAsync(serviceProvider, new TestMessage("payload"), CreateDeliverEventArgs(), (MessageEnvelope?)null, Mock.Of(), CancellationToken.None); + await handler.DispatchAsync(AutoMocker, new TestMessage("payload"), CreateDeliverEventArgs(), (MessageEnvelope?)null, _channelMock.Object, CancellationToken.None); // Assert trace.ShouldBe(["gate:before", "gate:short-circuit"]); @@ -186,13 +177,8 @@ public async Task RpcPipelineComposesFiltersAroundConsumerCall() // Arrange var trace = new List(); var consumerInstance = new RecordingRequestConsumer(); - var services = new ServiceCollection(); - services.AddScoped(_ => consumerInstance); - services.AddSingleton(Mock.Of()); - services.AddSingleton(Mock.Of()); - services.AddSingleton(TestProviders.Build()); - services.AddScoped>(_ => new RecordingFilter(trace, "log")); - var serviceProvider = services.BuildServiceProvider(); + Use(consumerInstance); + Use>>([new RecordingFilter(trace, "log")]); var queue = new QueueDefinition("TestQueue"); queue.AddConsumer(new RequestConsumerRegistration @@ -205,9 +191,8 @@ public async Task RpcPipelineComposesFiltersAroundConsumerCall() var handler = Target.GetPlan(new MessageType(typeof(TestRequest)).Name)!.Handlers.Single(h => h.Kind == HandlerKind.RequestConsumer); - var channel = GetMock(); ReadOnlyMemory publishedBody = default; - channel.Setup(x => x.BasicPublishAsync( + _channelMock.Setup(x => x.BasicPublishAsync( It.IsAny(), It.IsAny(), It.IsAny(), @@ -222,11 +207,11 @@ public async Task RpcPipelineComposesFiltersAroundConsumerCall() // Act await handler.DispatchAsync( - serviceProvider, + AutoMocker, new TestRequest("query"), CreateDeliverEventArgs(replyTo: "reply", correlationId: "corr-1"), (MessageEnvelope?)null, - channel.Object, + _channelMock.Object, CancellationToken.None); // Assert @@ -245,14 +230,8 @@ public async Task RpcPipelineShortCircuitProducesFailureResponse() { // Arrange var consumerInstance = new RecordingRequestConsumer(); - var services = new ServiceCollection(); - services.AddScoped(_ => consumerInstance); - services.AddSingleton(Mock.Of()); - services.AddSingleton(Mock.Of()); - services.AddSingleton(TestProviders.Build()); - services.AddScoped>(_ => - new RecordingFilter([], "gate") { ShortCircuit = true }); - var serviceProvider = services.BuildServiceProvider(); + Use(consumerInstance); + Use>>([new RecordingFilter([], "gate") { ShortCircuit = true }]); var queue = new QueueDefinition("TestQueue"); queue.AddConsumer(new RequestConsumerRegistration @@ -265,9 +244,8 @@ public async Task RpcPipelineShortCircuitProducesFailureResponse() var handler = Target.GetPlan(new MessageType(typeof(TestRequest)).Name)!.Handlers.Single(h => h.Kind == HandlerKind.RequestConsumer); - var channel = GetMock(); ReadOnlyMemory publishedBody = default; - channel.Setup(x => x.BasicPublishAsync( + _channelMock.Setup(x => x.BasicPublishAsync( It.IsAny(), It.IsAny(), It.IsAny(), @@ -282,11 +260,11 @@ public async Task RpcPipelineShortCircuitProducesFailureResponse() // Act await handler.DispatchAsync( - serviceProvider, + AutoMocker, new TestRequest("query"), CreateDeliverEventArgs(replyTo: "reply"), (MessageEnvelope?)null, - channel.Object, + _channelMock.Object, CancellationToken.None); // Assert diff --git a/tests/Vulthil.Messaging.RabbitMq.Tests/MessageTypeCacheTests.cs b/tests/Vulthil.Messaging.RabbitMq.Tests/MessageTypeCacheTests.cs index 8688d82..64fd4aa 100644 --- a/tests/Vulthil.Messaging.RabbitMq.Tests/MessageTypeCacheTests.cs +++ b/tests/Vulthil.Messaging.RabbitMq.Tests/MessageTypeCacheTests.cs @@ -20,7 +20,7 @@ public sealed class MessageTypeCacheTests : BaseUnitTestCase public MessageTypeCacheTests() { _lazyTarget = new Lazy(CreateInstance); - Use(TestProviders.Build()); + Use(TestProviders.Build()); Use>>([]); Use>>([]); diff --git a/tests/Vulthil.Messaging.RabbitMq.Tests/PolymorphicDispatchTests.cs b/tests/Vulthil.Messaging.RabbitMq.Tests/PolymorphicDispatchTests.cs index c92e1af..1144104 100644 --- a/tests/Vulthil.Messaging.RabbitMq.Tests/PolymorphicDispatchTests.cs +++ b/tests/Vulthil.Messaging.RabbitMq.Tests/PolymorphicDispatchTests.cs @@ -1,4 +1,3 @@ -using Microsoft.Extensions.DependencyInjection; using RabbitMQ.Client; using Vulthil.Messaging.Abstractions.Consumers; using Vulthil.Messaging.Abstractions.Publishers; @@ -16,7 +15,10 @@ public sealed class PolymorphicDispatchTests : BaseUnitTestCase public PolymorphicDispatchTests() { - Use(TestProviders.Build()); + Use(TestProviders.Build()); + Use>>([]); + Use>>([]); + Use>>([]); _lazyTarget = new Lazy(CreateInstance); } @@ -28,13 +30,9 @@ public async Task ConcreteDeliveryFiresEveryAssignableHandler() var orderInterfaceHits = 0; var orderEventInterfaceHits = 0; - var services = new ServiceCollection(); - services.AddScoped(_ => new ConcreteConsumer(() => concreteHits++)); - services.AddScoped(_ => new OrderInterfaceConsumer(() => orderInterfaceHits++)); - services.AddScoped(_ => new OrderEventInterfaceConsumer(() => orderEventInterfaceHits++)); - services.AddSingleton(Mock.Of()); - services.AddSingleton(Mock.Of()); - var serviceProvider = services.BuildServiceProvider(); + Use(new ConcreteConsumer(() => concreteHits++)); + Use(new OrderInterfaceConsumer(() => orderInterfaceHits++)); + Use(new OrderEventInterfaceConsumer(() => orderEventInterfaceHits++)); var queue = new QueueDefinition("orders"); queue.AddSubscription(new Subscription(new MessageType(typeof(OrderPlaced)))); @@ -65,7 +63,7 @@ public async Task ConcreteDeliveryFiresEveryAssignableHandler() // Act foreach (var handler in plan.Handlers) { - await handler.DispatchAsync(serviceProvider, message, ea, (MessageEnvelope?)null, Mock.Of(), CancellationToken.None); + await handler.DispatchAsync(AutoMocker, message, ea, (MessageEnvelope?)null, Mock.Of(), CancellationToken.None); } // Assert From 12bf5a6301dc83de56d291ec01363fb9424212d8 Mon Sep 17 00:00:00 2001 From: Vulthil Date: Fri, 5 Jun 2026 18:56:57 +0200 Subject: [PATCH 35/42] feat(messaging): publish Fault to the fault exchange by convention on consume failure --- docs/articles/messaging.md | 40 ++++++++++++++++ .../Consumers/RabbitMqConsumerWorker.cs | 44 +++++++++++++----- src/Vulthil.Messaging.RabbitMq/RabbitMqBus.cs | 10 ++++ .../IMessagingOptionsConfigurator.cs | 6 ++- .../MessagingConfigurationTests.cs | 45 ++++++++++++++++++ .../RabbitMqBusTopologyTests.cs | 21 +++++++++ .../RabbitMqConsumerWorkerTests.cs | 46 +++++++++++++++++++ 7 files changed, 198 insertions(+), 14 deletions(-) diff --git a/docs/articles/messaging.md b/docs/articles/messaging.md index 43f479b..a6d1c8c 100644 --- a/docs/articles/messaging.md +++ b/docs/articles/messaging.md @@ -404,6 +404,46 @@ execution modes: On exhaustion the message is dead-lettered (when a dead-letter queue is configured) in both modes. +## Faults + +When a consumed message fails terminally — every retry exhausted — a `Fault` is published +**by convention** to a shared topic exchange (default `Fault.Exchange`, configurable via +`Messaging:Options:FaultExchangeName`). No per-message opt-in by the producer is required, so faults +are observable broker-side without changing any producer. The faulted message's URN is the routing +key, so an operator binds a queue to the fault exchange — `#` for every fault, or a specific URN to +filter by faulted message type — and reads the payload. The fault body is a `Fault` JSON document +(the AMQP `type` is `Fault<{original-urn}>`): + +```csharp +public record Fault where TMessage : notnull +{ + public required TMessage Message { get; init; } // the original message body + public required string ExceptionMessage { get; init; } + public required string? StackTrace { get; init; } + public required string ExceptionType { get; init; } + public required DateTimeOffset FaultedAt { get; init; } + public required MessageContextSnapshot OriginalContext { get; init; } // original transport metadata +} +``` + +The fault exchange is a diagnostics/observability broadcast — drain it with a monitoring service or +any AMQP consumer bound to the exchange — rather than a typed `IConsumer>` endpoint. + +A message can override the routing per-message: if it carries an explicit `FaultAddress`, the fault is +routed **point-to-point** to that address (through the broker's default exchange) instead of being +broadcast to the fault exchange — exactly one fault is emitted either way. Set it on publish: + +```csharp +await publisher.PublishAsync(new OrderCreatedEvent(orderId), ctx => +{ + ctx.SetFaultAddress(new Uri("queue:order-faults")); + return ValueTask.CompletedTask; +}); +``` + +Fault publishing is best-effort: a failure to publish the fault is logged and never prevents the +original delivery from being settled (nacked for dead-lettering). + ## Routing Keys Routing keys flow through two distinct configuration sites, one on each side of the wire: diff --git a/src/Vulthil.Messaging.RabbitMq/Consumers/RabbitMqConsumerWorker.cs b/src/Vulthil.Messaging.RabbitMq/Consumers/RabbitMqConsumerWorker.cs index c152385..fc5e404 100644 --- a/src/Vulthil.Messaging.RabbitMq/Consumers/RabbitMqConsumerWorker.cs +++ b/src/Vulthil.Messaging.RabbitMq/Consumers/RabbitMqConsumerWorker.cs @@ -172,7 +172,7 @@ private async Task ProcessAsync(PreparedDelivery prepared, BasicDeliverEventArgs /// /// Re-invokes the consumer in-process up to the policy's retry count, holding the delivery (and, on a /// partitioned queue, its lane) so a later message cannot overtake the one being retried. Each attempt - /// runs in a fresh scope. On exhaustion the message is faulted (if requested) and nacked for dead-lettering. + /// runs in a fresh scope. On exhaustion a fault is published and the message is nacked for dead-lettering. /// private async Task ExecuteWithInMemoryRetryAsync(RetryPolicyDefinition policy, PreparedDelivery prepared, BasicDeliverEventArgs ea, Activity? activity) { @@ -201,7 +201,7 @@ private async Task ExecuteWithInMemoryRetryAsync(RetryPolicyDefinition policy, P activity?.SetStatus(ActivityStatusCode.Error, ex.Message); activity?.AddException(ex); MessagingLog.ConsumerFailed(_logger, ex, _queueDefinition.Name, prepared.DiagnosticTypeName, ea.RoutingKey); - await PublishFaultIfRequestedAsync(ex, ea, ea.BasicProperties.Headers ?? new Dictionary()); + await PublishFaultAsync(ex, ea, ea.BasicProperties.Headers ?? new Dictionary(), prepared.DiagnosticTypeName); await NackAsync(ea); return; } @@ -257,7 +257,7 @@ private async Task HandleFailureAsync(Exception ex, BasicDeliverEventArgs ea, st if (policy is null) { MessagingLog.ConsumerFailed(_logger, ex, _queueDefinition.Name, messageTypeName, ea.RoutingKey); - await PublishFaultIfRequestedAsync(ex, ea, headers); + await PublishFaultAsync(ex, ea, headers, messageTypeName); await NackAsync(ea); return; } @@ -281,17 +281,20 @@ private async Task HandleFailureAsync(Exception ex, BasicDeliverEventArgs ea, st } MessagingLog.ConsumerFailed(_logger, ex, _queueDefinition.Name, messageTypeName, ea.RoutingKey); - await PublishFaultIfRequestedAsync(ex, ea, headers); + await PublishFaultAsync(ex, ea, headers, messageTypeName); await NackAsync(ea); } - private async Task PublishFaultIfRequestedAsync(Exception ex, BasicDeliverEventArgs ea, IDictionary headers) + /// + /// Publishes a for a terminally-failed delivery. When the delivery carries an + /// explicit FaultAddress the fault is routed point-to-point to that address (via the broker's default + /// exchange); otherwise it is published by convention to the shared fault exchange with the faulted message's + /// URN as the routing key. Exactly one fault is emitted per failure. Publishing is best-effort: a failure to + /// publish the fault is logged and never disrupts settling the original delivery. + /// + private async Task PublishFaultAsync(Exception ex, BasicDeliverEventArgs ea, IDictionary headers, string messageTypeName) { - var faultAddressKey = RabbitMqConstants.GetHeaderString(headers, "FaultAddress"); - if (string.IsNullOrEmpty(faultAddressKey)) - { - return; - } + var (exchange, routingKey) = ResolveFaultRoute(headers, _messageConfigurationProvider.FaultExchangeName, messageTypeName); try { @@ -315,14 +318,31 @@ private async Task PublishFaultIfRequestedAsync(Exception ex, BasicDeliverEventA Timestamp = new AmqpTimestamp(DateTimeOffset.UtcNow.ToUnixTimeSeconds()) }; - await OnChannelAsync(() => _channel.BasicPublishAsync(_messageConfigurationProvider.FaultExchangeName, faultAddressKey, false, faultProps, faultBody)); + await OnChannelAsync(() => _channel.BasicPublishAsync(exchange, routingKey, false, faultProps, faultBody)); } catch (Exception faultEx) { - MessagingLog.FaultPublishFailed(_logger, faultEx, _messageConfigurationProvider.FaultExchangeName, faultAddressKey); + MessagingLog.FaultPublishFailed(_logger, faultEx, exchange, routingKey); } } + /// + /// Resolves the broker route for a fault. A delivery carrying an explicit FaultAddress routes + /// point-to-point through the broker's default exchange (empty exchange, the address's queue name as the + /// routing key); otherwise the fault is published by convention to with + /// the faulted message's URN () as the routing key. + /// + internal static (string Exchange, string RoutingKey) ResolveFaultRoute( + IDictionary headers, + string faultExchangeName, + string messageTypeName) + { + var faultAddress = RabbitMqConstants.GetHeaderUri(headers, "FaultAddress"); + return faultAddress is null + ? (faultExchangeName, messageTypeName) + : (string.Empty, RabbitMqAddress.ResolveRoutingKey(faultAddress) ?? string.Empty); + } + private static RetryPolicyDefinition? GetPolicy(RabbitMqPlan? plan, QueueDefinition queue) { if (plan is not null) diff --git a/src/Vulthil.Messaging.RabbitMq/RabbitMqBus.cs b/src/Vulthil.Messaging.RabbitMq/RabbitMqBus.cs index d5a8a2e..c69751c 100644 --- a/src/Vulthil.Messaging.RabbitMq/RabbitMqBus.cs +++ b/src/Vulthil.Messaging.RabbitMq/RabbitMqBus.cs @@ -102,6 +102,16 @@ private async Task SetupTopology(IReadOnlyCollection queues, Ca { using var channel = await _connection.CreateChannelAsync(cancellationToken: cancellationToken); + // The fault exchange is a shared topic exchange: every terminal consume failure publishes a Fault + // here by convention with the faulted message's URN as the routing key, so a subscriber binds its queue + // with "#" to observe all faults or with a specific URN to filter by faulted message type. + await channel.ExchangeDeclareAsync( + exchange: _messageConfigurationProvider.FaultExchangeName, + type: ExchangeType.Topic, + durable: true, + autoDelete: false, + cancellationToken: cancellationToken); + foreach (var queue in queues) { await SetupQueueTopology(queue, channel, cancellationToken); diff --git a/src/Vulthil.Messaging/IMessagingOptionsConfigurator.cs b/src/Vulthil.Messaging/IMessagingOptionsConfigurator.cs index 23d95a0..b82209c 100644 --- a/src/Vulthil.Messaging/IMessagingOptionsConfigurator.cs +++ b/src/Vulthil.Messaging/IMessagingOptionsConfigurator.cs @@ -24,8 +24,10 @@ public interface IMessagingOptionsConfigurator TimeSpan DefaultTimeout { get; set; } /// - /// Gets or sets the name of the exchange to which faults are published when a consumed message - /// carries a FaultAddress header. Default is "Fault.Exchange". + /// Gets or sets the name of the shared topic exchange to which a Fault<T> is published by + /// convention whenever a consumed message fails terminally (after retries are exhausted), using the faulted + /// message's URN as the routing key. A delivery that carries an explicit FaultAddress is routed + /// point-to-point to that address instead. Default is "Fault.Exchange". /// string FaultExchangeName { get; set; } diff --git a/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.Tests/MessagingConfigurationTests.cs b/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.Tests/MessagingConfigurationTests.cs index b4b7112..ea008c9 100644 --- a/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.Tests/MessagingConfigurationTests.cs +++ b/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.Tests/MessagingConfigurationTests.cs @@ -1,6 +1,7 @@ using System.Diagnostics; using System.Net; using System.Net.Http.Json; +using System.Text; using RabbitMQ.Client; using Vulthil.Extensions.Testing; using Vulthil.Messaging.IntegrationTest.Contracts; @@ -207,6 +208,50 @@ public async Task PoisonConsumerDeadLettersAfterRetriesAreExhausted() attempts.ShouldBe(2); } + [Fact] + public async Task PoisonConsumerPublishesAFaultToTheFaultExchangeByConvention() + { + var cancellationToken = TestContext.Current.CancellationToken; + + var connectionString = await fixture.GetRabbitMqConnectionStringAsync(cancellationToken); + connectionString.ShouldNotBeNull(); + + var factory = new ConnectionFactory { Uri = new Uri(connectionString) }; + await using var connection = await factory.CreateConnectionAsync(cancellationToken); + await using var channel = await connection.CreateChannelAsync(cancellationToken: cancellationToken); + + // Bind an observer queue to the fault exchange BEFORE sending, so the by-convention broadcast is captured. + var observer = await channel.QueueDeclareAsync(cancellationToken: cancellationToken); + await channel.QueueBindAsync(observer.QueueName, "Fault.Exchange", "#", cancellationToken: cancellationToken); + + var command = new PoisonCommand(Guid.NewGuid()); + using var sendResponse = await fixture.ProducerClient.PostAsJsonAsync( + "/api/send-poison", + command, + cancellationToken); + sendResponse.IsSuccessStatusCode.ShouldBeTrue(); + + var faultResult = await Polling.WaitAsync( + PollTimeout, + async ct => + { + var message = await channel.BasicGetAsync(observer.QueueName, autoAck: true, ct); + if (message is null) + { + return Result.Failure(Error.NotFound("Fault.Empty", "No fault published yet.")); + } + + var body = Encoding.UTF8.GetString(message.Body.Span); + return body.Contains(command.Id.ToString(), StringComparison.OrdinalIgnoreCase) + ? Result.Success(true) + : Result.Failure(Error.NotFound("Fault.Mismatch", "Fault for another message.")); + }, + PollInterval, + cancellationToken); + + faultResult.IsSuccess.ShouldBeTrue(); + } + [Fact] public async Task RequestToAFaultingConsumerSurfacesAFailure() { diff --git a/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqBusTopologyTests.cs b/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqBusTopologyTests.cs index cd742cd..f3c629a 100644 --- a/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqBusTopologyTests.cs +++ b/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqBusTopologyTests.cs @@ -13,6 +13,7 @@ public sealed class RabbitMqBusTopologyTests : BaseUnitTestCase private const string SingleActiveConsumerArgument = "x-single-active-consumer"; private readonly Dictionary> _declaredQueues = new(StringComparer.Ordinal); + private readonly Dictionary _declaredExchanges = new(StringComparer.Ordinal); private readonly Lazy _lazyTarget; private RabbitMqBus Target => _lazyTarget.Value; @@ -27,6 +28,13 @@ public RabbitMqBusTopologyTests() .Callback((string queue, bool _, bool _, bool _, IDictionary arguments, bool _, bool _, CancellationToken _) => _declaredQueues[queue] = arguments) .ReturnsAsync(new QueueDeclareOk("queue", 0, 0)); + channel + .Setup(c => c.ExchangeDeclareAsync( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((string exchange, string type, bool _, bool _, IDictionary _, bool _, bool _, CancellationToken _) => + _declaredExchanges[exchange] = type) + .Returns(Task.CompletedTask); channel .Setup(c => c.BasicConsumeAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), @@ -93,6 +101,19 @@ public async Task ExplicitlyConfiguredQueueIsDeclaredWithSingleActiveConsumerArg _declaredQueues["sole"][SingleActiveConsumerArgument].ShouldBe(true); } + [Fact] + public async Task FaultExchangeIsDeclaredAsADurableTopicExchange() + { + // Arrange + var provider = ProviderConsumingOrderedEvents("plain"); + + // Act + await DeclareTopologyAsync(provider); + + // Assert + _declaredExchanges.ShouldContainKeyAndValue("Fault.Exchange", ExchangeType.Topic); + } + [Fact] public async Task PartitionedQueueAutomaticallyEnablesSingleActiveConsumer() { diff --git a/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqConsumerWorkerTests.cs b/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqConsumerWorkerTests.cs index 9f8e11c..b2a7048 100644 --- a/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqConsumerWorkerTests.cs +++ b/tests/Vulthil.Messaging.RabbitMq.Tests/RabbitMqConsumerWorkerTests.cs @@ -1,3 +1,4 @@ +using System.Text; using RabbitMQ.Client; using RabbitMQ.Client.Events; using Vulthil.Messaging.RabbitMq.Consumers; @@ -7,6 +8,51 @@ namespace Vulthil.Messaging.RabbitMq.Tests; public sealed class RabbitMqConsumerWorkerTests : BaseUnitTestCase { + private const string FaultExchange = "Fault.Exchange"; + private const string MessageUrn = "urn:message:Acme.Orders:OrderCreatedEvent"; + + [Fact] + public void ResolveFaultRouteBroadcastsToTheFaultExchangeWhenNoFaultAddressIsPresent() + { + // Arrange + var headers = new Dictionary(); + + // Act + var (exchange, routingKey) = RabbitMqConsumerWorker.ResolveFaultRoute(headers, FaultExchange, MessageUrn); + + // Assert + exchange.ShouldBe(FaultExchange); + routingKey.ShouldBe(MessageUrn); + } + + [Fact] + public void ResolveFaultRouteRoutesPointToPointThroughTheDefaultExchangeWhenFaultAddressIsPresent() + { + // Arrange + var headers = new Dictionary { ["FaultAddress"] = "queue:order-faults" }; + + // Act + var (exchange, routingKey) = RabbitMqConsumerWorker.ResolveFaultRoute(headers, FaultExchange, MessageUrn); + + // Assert + exchange.ShouldBe(string.Empty); + routingKey.ShouldBe("order-faults"); + } + + [Fact] + public void ResolveFaultRouteReadsTheFaultAddressFromAWireEncodedHeaderValue() + { + // Arrange — RabbitMQ surfaces header values as UTF-8 byte arrays. + var headers = new Dictionary { ["FaultAddress"] = Encoding.UTF8.GetBytes("queue:order-faults") }; + + // Act + var (exchange, routingKey) = RabbitMqConsumerWorker.ResolveFaultRoute(headers, FaultExchange, MessageUrn); + + // Assert + exchange.ShouldBe(string.Empty); + routingKey.ShouldBe("order-faults"); + } + [Fact] public void WithRetryCountSurfacesTheAttemptThroughMessageContextRetryCount() { From 51506b173ccca8af942f57734b9991505f76e4dc Mon Sep 17 00:00:00 2001 From: Vulthil Date: Fri, 5 Jun 2026 19:45:59 +0200 Subject: [PATCH 36/42] build(aspire): centralize SDK version in global.json and bump Aspire to 13.4.2 Co-Authored-By: Claude Opus 4.8 (1M context) --- Directory.Packages.props | 2 +- global.json | 3 +++ samples/AppHost/AppHost/AppHost.csproj | 2 +- .../Vulthil.Messaging.IntegrationTest.AppHost.csproj | 2 +- 4 files changed, 6 insertions(+), 3 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index f6ed776..574b68b 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -6,7 +6,7 @@ 10.0.8 9.0.16 - 13.3.5 + 13.4.2 diff --git a/global.json b/global.json index 01b5fad..f4a7103 100644 --- a/global.json +++ b/global.json @@ -6,5 +6,8 @@ }, "test": { "runner": "Microsoft.Testing.Platform" + }, + "msbuild-sdks": { + "Aspire.AppHost.Sdk": "13.4.2" } } \ No newline at end of file diff --git a/samples/AppHost/AppHost/AppHost.csproj b/samples/AppHost/AppHost/AppHost.csproj index eac5293..b773030 100644 --- a/samples/AppHost/AppHost/AppHost.csproj +++ b/samples/AppHost/AppHost/AppHost.csproj @@ -1,4 +1,4 @@ - + Exe diff --git a/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.AppHost/Vulthil.Messaging.IntegrationTest.AppHost.csproj b/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.AppHost/Vulthil.Messaging.IntegrationTest.AppHost.csproj index 0ac8d69..8f2846f 100644 --- a/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.AppHost/Vulthil.Messaging.IntegrationTest.AppHost.csproj +++ b/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.AppHost/Vulthil.Messaging.IntegrationTest.AppHost.csproj @@ -1,4 +1,4 @@ - + f7b7f8ec-0e80-44c4-bf3f-6bbdcf5d7b64 From 6217b3d17fa7a48193a469e5560ac49f2a286f39 Mon Sep 17 00:00:00 2001 From: Vulthil Date: Fri, 5 Jun 2026 20:30:42 +0200 Subject: [PATCH 37/42] feat(messaging): add in-memory ITestHarness transport on the public Transport SDK --- Vulthil.SharedKernel.slnx | 1 + docs/articles/messaging.md | 22 +- .../packages/vulthil-messaging-testharness.md | 59 +++- docs/articles/testing.md | 67 ++++- .../TestHarnessWebApplicationFactory.cs | 27 ++ .../TestHarnessIntegrationTests.cs | 41 +++ .../WebApi/WebApi.Tests/WebApi.Tests.csproj | 1 + .../CapturedMessage.cs | 13 + .../ITestHarness.cs | 57 ++++ .../InMemoryContext.cs | 35 +++ .../InMemoryHandler.cs | 14 + .../InMemoryHandlerFactory.cs | 38 +++ .../InMemoryMessageHandlers.cs | 76 +++++ .../InMemoryPublisher.cs | 42 +++ .../InMemoryReply.cs | 38 +++ .../InMemoryRequester.cs | 82 ++++++ .../InMemorySendEndpoint.cs | 69 +++++ .../InMemoryTransport.cs | 91 ++++++ .../OutgoingEnvelope.cs | 28 ++ .../PublicAPI.Unshipped.txt | 17 ++ src/Vulthil.Messaging.TestHarness/README.md | 20 +- .../TestHarness.cs | 109 ++++++++ .../TestHarnessExtensions.cs | 57 ++++ .../BaseWebApplicationFactory.cs | 4 +- tests/Directory.Build.props | 2 +- .../TestHarnessTests.cs | 261 ++++++++++++++++++ ...Vulthil.Messaging.TestHarness.Tests.csproj | 9 + 27 files changed, 1254 insertions(+), 26 deletions(-) create mode 100644 samples/WebApi/WebApi.Tests/Fixtures/TestHarnessWebApplicationFactory.cs create mode 100644 samples/WebApi/WebApi.Tests/TestHarnessIntegrationTests.cs create mode 100644 src/Vulthil.Messaging.TestHarness/CapturedMessage.cs create mode 100644 src/Vulthil.Messaging.TestHarness/ITestHarness.cs create mode 100644 src/Vulthil.Messaging.TestHarness/InMemoryContext.cs create mode 100644 src/Vulthil.Messaging.TestHarness/InMemoryHandler.cs create mode 100644 src/Vulthil.Messaging.TestHarness/InMemoryHandlerFactory.cs create mode 100644 src/Vulthil.Messaging.TestHarness/InMemoryMessageHandlers.cs create mode 100644 src/Vulthil.Messaging.TestHarness/InMemoryPublisher.cs create mode 100644 src/Vulthil.Messaging.TestHarness/InMemoryReply.cs create mode 100644 src/Vulthil.Messaging.TestHarness/InMemoryRequester.cs create mode 100644 src/Vulthil.Messaging.TestHarness/InMemorySendEndpoint.cs create mode 100644 src/Vulthil.Messaging.TestHarness/InMemoryTransport.cs create mode 100644 src/Vulthil.Messaging.TestHarness/OutgoingEnvelope.cs create mode 100644 src/Vulthil.Messaging.TestHarness/TestHarness.cs create mode 100644 src/Vulthil.Messaging.TestHarness/TestHarnessExtensions.cs create mode 100644 tests/Vulthil.Messaging.TestHarness.Tests/TestHarnessTests.cs create mode 100644 tests/Vulthil.Messaging.TestHarness.Tests/Vulthil.Messaging.TestHarness.Tests.csproj diff --git a/Vulthil.SharedKernel.slnx b/Vulthil.SharedKernel.slnx index 83d2869..06a6830 100644 --- a/Vulthil.SharedKernel.slnx +++ b/Vulthil.SharedKernel.slnx @@ -85,6 +85,7 @@ + diff --git a/docs/articles/messaging.md b/docs/articles/messaging.md index a6d1c8c..8e04a9a 100644 --- a/docs/articles/messaging.md +++ b/docs/articles/messaging.md @@ -917,13 +917,23 @@ var reply = new MessageEnvelope ## Testing Messaging -`Vulthil.Messaging.TestHarness` provides an in-memory transport that captures -published messages for assertion: +`Vulthil.Messaging.TestHarness` provides an in-memory transport that runs your consumers with no broker and +captures produced and consumed messages for assertion. Compose it with `UseTestHarness()` (in place of a broker +transport) or swap an existing transport in an integration test with `ReplaceTransportWithTestHarness()`: ```csharp -var published = testHarness.Published(); -Assert.Single(published); -Assert.Equal(expectedOrderId, published.First().Message.OrderId); +builder.AddMessaging(messaging => +{ + messaging.ConfigureQueue("orders", q => q.AddConsumer()); + messaging.UseTestHarness(); +}); + +// ...after building the host and publishing: +var harness = host.Services.GetRequiredService(); +harness.Published().ShouldHaveSingleItem().Message.OrderId.ShouldBe(expectedOrderId); +harness.Consumed().ShouldHaveSingleItem(); ``` -See [Testing](testing.md) for more details on integration test setup. +The harness is built entirely on the `Vulthil.Messaging.Transport` SDK above, so it is also a worked example of a +custom transport. See [Testing](testing.md#messaging-test-harness) for the full API (`Published`/`Sent`/ +`Consumed`/`Requested`, the `Respond`/`Handle` response stubs, and the integration-test swap). diff --git a/docs/articles/packages/vulthil-messaging-testharness.md b/docs/articles/packages/vulthil-messaging-testharness.md index 098c029..e9fe173 100644 --- a/docs/articles/packages/vulthil-messaging-testharness.md +++ b/docs/articles/packages/vulthil-messaging-testharness.md @@ -1,26 +1,61 @@ # Vulthil.Messaging.TestHarness -Use `Vulthil.Messaging.TestHarness` to validate messaging behavior in tests. +An in-memory messaging transport for tests. It runs your consumers with no broker and captures every produced +and consumed message for assertion. Built entirely on the public `Vulthil.Messaging.Transport` SDK, so it +mirrors the real consumer topology assembled from your queue configuration. ## When to use -- Integration tests that assert published/consumed messages -- End-to-end verification of messaging flows +- Component/unit tests that assert published, sent, consumed, or requested messages +- Integration tests that exercise the production composition without a broker +- Standing in for an external service (mock a request reply or a downstream event handler) ## Pattern -- Treat message assertions as behavior verification -- Keep test setup explicit and deterministic -- Isolate external broker dependencies when possible +- Dispatch is synchronous: when a publish/send/request call returns, every consumer it triggered has run — no polling +- A one-way consumer's exception propagates to the publisher/sender; a request consumer's exception becomes a failed result +- Keep assertions on `ITestHarness` deterministic and explicit; `Clear()` between phases ## Usage -### Verifying published messages +### Compose the harness (unit/component tests) ```csharp -// Replace the real publisher with the test harness in your WebApplicationFactory, -// then assert that expected messages were published after an action: -var published = testHarness.Published(); -Assert.Single(published); -Assert.Equal(expectedOrderId, published.First().Message.OrderId); +var builder = Host.CreateApplicationBuilder(); +builder.AddMessaging(messaging => +{ + messaging.ConfigureQueue("orders", q => q.AddConsumer()); + messaging.UseTestHarness(); +}); +using var host = builder.Build(); + +var publisher = host.Services.GetRequiredService(); +var harness = host.Services.GetRequiredService(); + +await publisher.PublishAsync(new OrderCreatedEvent(orderId)); + +harness.Published().ShouldHaveSingleItem().Message.OrderId.ShouldBe(orderId); +harness.Consumed().ShouldHaveSingleItem(); +``` + +### Mock responses + +```csharp +// Answer a request as an external service would (takes precedence over a real request consumer): +harness.Respond(ctx => new WeatherForecast(ctx.Message.City, 20)); + +// React to a published/sent message as a fake downstream service: +harness.Handle(ctx => { observed.Add(ctx.Message.OrderId); return Task.CompletedTask; }); ``` + +### Swap the transport in integration tests + +Call `ReplaceTransportWithTestHarness()` from a test host's service hook to replace the registered broker +transport with the harness, leaving production code untouched: + +```csharp +builder.ConfigureServices(services => services.ReplaceTransportWithTestHarness()); +``` + +See the [Testing guide](https://github.com/Vulthil/Vulthil.SharedKernel/tree/main/docs/articles/testing.md) for +the full API and details. diff --git a/docs/articles/testing.md b/docs/articles/testing.md index ce1cf12..dbe9baa 100644 --- a/docs/articles/testing.md +++ b/docs/articles/testing.md @@ -182,12 +182,71 @@ Mock state is reset after each test (like the database), so stubs and captured r ## Messaging Test Harness -Replace the real transport with the test harness to assert messaging behaviour without a broker: +`Vulthil.Messaging.TestHarness` provides an in-memory transport that runs your consumers with no broker and +captures every produced and consumed message for assertion. It is built entirely on the public +`Vulthil.Messaging.Transport` SDK, so it mirrors the real consumer topology assembled from your queue +configuration. Dispatch is synchronous — by the time a publish/send/request call returns, every consumer (and +stub) it triggered has run, so assertions need no polling. + +### Composing a harness (unit/component tests) + +Call `UseTestHarness()` in place of a broker transport, then resolve `ITestHarness` alongside the usual +`IPublisher`/`ISendEndpoint`/`IRequester`: + +```csharp +var builder = Host.CreateApplicationBuilder(); +builder.AddMessaging(messaging => +{ + messaging.ConfigureQueue("orders", q => q.AddConsumer()); + messaging.UseTestHarness(); +}); +using var host = builder.Build(); + +var publisher = host.Services.GetRequiredService(); +var harness = host.Services.GetRequiredService(); + +await publisher.PublishAsync(new OrderCreatedEvent(orderId)); + +harness.Published().ShouldHaveSingleItem().Message.OrderId.ShouldBe(orderId); +harness.Consumed().ShouldHaveSingleItem(); +``` + +`ITestHarness` exposes `Published()`, `Sent()`, `Consumed()`, and `Requested()` (each returns the +matching `CapturedMessage` items — `.Message` is the payload, `.Envelope` the wire metadata), plus `Clear()`. + +### Mocking responses + +A test can stand in for an external service. `Respond` answers a request (taking precedence +over a real request consumer), and `Handle` reacts to a published or sent message — useful to fake a +downstream service that publishes a follow-up: ```csharp -var published = testHarness.Published(); -Assert.Single(published); -Assert.Equal(expectedOrderId, published.First().Message.OrderId); +harness.Respond(ctx => new WeatherForecast(ctx.Message.City, 20)); +harness.Handle(ctx => { observed.Add(ctx.Message.OrderId); return Task.CompletedTask; }); + +var result = await requester.RequestAsync(new GetWeatherRequest("Oslo")); +result.Value.TemperatureC.ShouldBe(20); +``` + +A request with neither a responder nor a registered request consumer completes with a +`Messaging.Request.NoConsumer` failure; a request consumer that throws surfaces as a `Messaging.Request.Failure`. + +### Swapping the transport in integration tests + +To exercise the production composition root without a broker, call `ReplaceTransportWithTestHarness()` from the +test host's service hook (for example a `WebApplicationFactory`). It swaps the registered transport for the +harness and leaves the rest of the application untouched — production code is not modified for tests: + +```csharp +public sealed class AppWebFactory : BaseWebApplicationFactory +{ + protected override void ConfigureCustomWebHost(IWebHostBuilder builder) + => builder.ConfigureServices(services => services.ReplaceTransportWithTestHarness()); +} ``` +The orphaned broker registrations remain but are never resolved, so no connection is attempted. Disable the +broker's own health check via configuration (for example `Aspire:RabbitMQ:Client:DisableHealthChecks`) if a +readiness probe would otherwise wait on it. + See [Messaging](messaging.md) for more on the messaging architecture. diff --git a/samples/WebApi/WebApi.Tests/Fixtures/TestHarnessWebApplicationFactory.cs b/samples/WebApi/WebApi.Tests/Fixtures/TestHarnessWebApplicationFactory.cs new file mode 100644 index 0000000..5decb7e --- /dev/null +++ b/samples/WebApi/WebApi.Tests/Fixtures/TestHarnessWebApplicationFactory.cs @@ -0,0 +1,27 @@ +using Microsoft.AspNetCore.Hosting; +using ServiceDefaults; +using Vulthil.Messaging.TestHarness; +using Vulthil.xUnit; +using Xunit.Sdk; + +namespace WebApi.Tests.Fixtures; + +/// +/// Boots the WebApi sample with its production messaging composition, then swaps the broker transport for the +/// in-memory test harness — demonstrating an integration test that runs the real consumers with a real database +/// but no message broker. Only a PostgreSQL container is started. +/// +public sealed class TestHarnessWebApplicationFactory : BaseWebApplicationFactory +{ + public TestHarnessWebApplicationFactory(IMessageSink messageSink) + => AddContainer(new PostgreSqlTestContainer(messageSink)); + + protected override void ConfigureCustomWebHost(IWebHostBuilder builder) + { + // No broker container runs, so stub the connection string to satisfy the RabbitMQ client registration; + // swapping the transport means the broker connection is never resolved. + builder.UseSetting($"ConnectionStrings:{ServiceNames.RabbitMqServiceName}", "amqp://guest:guest@localhost:5672"); + builder.ConfigureServices(services => services.ReplaceTransportWithTestHarness()); + } + +} diff --git a/samples/WebApi/WebApi.Tests/TestHarnessIntegrationTests.cs b/samples/WebApi/WebApi.Tests/TestHarnessIntegrationTests.cs new file mode 100644 index 0000000..3605675 --- /dev/null +++ b/samples/WebApi/WebApi.Tests/TestHarnessIntegrationTests.cs @@ -0,0 +1,41 @@ +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Vulthil.Messaging.Abstractions.Publishers; +using Vulthil.Messaging.TestHarness; +using Vulthil.xUnit; +using WebApi.Application.MainEntities.Create; +using WebApi.Application.SideEffects; +using WebApi.Application.SideEffects.Create; +using WebApi.Tests.Fixtures; + +namespace WebApi.Tests; + +public sealed class TestHarnessIntegrationTests(TestHarnessWebApplicationFactory factory, ITestOutputHelper testOutputHelper) + : BaseIntegrationTestCase(factory, testOutputHelper), IClassFixture +{ + private ITestHarness Harness => Factory.Services.GetRequiredService(); + private IPublisher Publisher => Factory.Services.GetRequiredService(); + private IRequester Requester => Factory.Services.GetRequiredService(); + + [Fact] + public async Task PublishingAnIntegrationEventRunsTheRealConsumerThroughTheInMemoryHarness() + { + // Arrange + var mainEntityId = Guid.NewGuid(); + + // Act — the harness dispatches synchronously, with no broker. + await Publisher.PublishAsync(new MainEntityCreatedIntegrationEvent(mainEntityId), CancellationToken); + + // Assert — the event was captured and the real consumer ran (no polling needed). + Harness.Published().ShouldHaveSingleItem().Message.Id.ShouldBe(mainEntityId); + Harness.Consumed().ShouldHaveSingleItem().Message.Id.ShouldBe(mainEntityId); + + // The consumer wrote a side effect to the real database; read it back through the request consumer. + var sideEffects = await Requester.RequestAsync>( + new GetSideEffectsBelongingToMainEntity(mainEntityId), + CancellationToken); + + sideEffects.IsSuccess.ShouldBeTrue(); + sideEffects.Value.ShouldContain(sideEffect => sideEffect.MainEntityId == mainEntityId); + } +} diff --git a/samples/WebApi/WebApi.Tests/WebApi.Tests.csproj b/samples/WebApi/WebApi.Tests/WebApi.Tests.csproj index c231f04..633e088 100644 --- a/samples/WebApi/WebApi.Tests/WebApi.Tests.csproj +++ b/samples/WebApi/WebApi.Tests/WebApi.Tests.csproj @@ -26,6 +26,7 @@ + diff --git a/src/Vulthil.Messaging.TestHarness/CapturedMessage.cs b/src/Vulthil.Messaging.TestHarness/CapturedMessage.cs new file mode 100644 index 0000000..196f125 --- /dev/null +++ b/src/Vulthil.Messaging.TestHarness/CapturedMessage.cs @@ -0,0 +1,13 @@ +using Vulthil.Messaging.Transport; + +namespace Vulthil.Messaging.TestHarness; + +/// +/// A single message captured by the : the deserialized payload and the wire +/// it travelled in (correlation/conversation ids, headers, addresses, …). +/// +/// The captured message type. +/// The deserialized message payload. +/// The wire envelope carrying the message metadata. +public sealed record CapturedMessage(TMessage Message, MessageEnvelope Envelope) + where TMessage : notnull; diff --git a/src/Vulthil.Messaging.TestHarness/ITestHarness.cs b/src/Vulthil.Messaging.TestHarness/ITestHarness.cs new file mode 100644 index 0000000..985252d --- /dev/null +++ b/src/Vulthil.Messaging.TestHarness/ITestHarness.cs @@ -0,0 +1,57 @@ +using Vulthil.Messaging.Abstractions.Consumers; + +namespace Vulthil.Messaging.TestHarness; + +/// +/// In-memory messaging test harness. Captures every message produced and consumed through the in-memory +/// transport so tests can assert on messaging behaviour with no broker, and lets a test stand in for an +/// external service by handling published messages or responding to requests. +/// +/// +/// The harness dispatches synchronously: by the time a publish, send, or request call completes, every +/// consumer (and registered / stub) +/// it triggered has run, so assertions need no polling. An exception thrown by a one-way consumer propagates +/// to the caller of publish/send; a request consumer's exception is surfaced as a failed request result. +/// +public interface ITestHarness +{ + /// Gets the messages published via IPublisher.PublishAsync that are assignable to , in order. + /// The message type to filter by. + IReadOnlyList> Published() where TMessage : notnull; + + /// Gets the messages sent via ISendEndpoint.SendAsync that are assignable to , in order. + /// The message type to filter by. + IReadOnlyList> Sent() where TMessage : notnull; + + /// Gets the messages delivered to a registered consumer that are assignable to , in order. A message handled by several consumers is captured once per consumer. + /// The message type to filter by. + IReadOnlyList> Consumed() where TMessage : notnull; + + /// Gets the requests issued via IRequester.RequestAsync that are assignable to , in order. + /// The request type to filter by. + IReadOnlyList> Requested() where TMessage : notnull; + + /// + /// Registers a stub that runs whenever a is published or sent, in addition to + /// any registered consumers. Use it to stand in for a downstream service — the stub can publish or send + /// follow-up messages through its context. + /// + /// The message type to react to. + /// The stub invoked with the delivered message context. + void Handle(Func, Task> handler) where TMessage : notnull; + + /// + /// Registers a stub that answers requests of type with a + /// , standing in for an external request consumer. A registered responder + /// takes precedence over a real request consumer for the same request type. + /// + /// The request type to answer. + /// The response type to return. + /// The stub invoked with the request context to produce the response. + void Respond(Func, TResponse> responder) + where TRequest : notnull + where TResponse : notnull; + + /// Clears all captured published, sent, consumed, and requested messages. Registered stubs are retained. + void Clear(); +} diff --git a/src/Vulthil.Messaging.TestHarness/InMemoryContext.cs b/src/Vulthil.Messaging.TestHarness/InMemoryContext.cs new file mode 100644 index 0000000..34064ea --- /dev/null +++ b/src/Vulthil.Messaging.TestHarness/InMemoryContext.cs @@ -0,0 +1,35 @@ +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using Vulthil.Messaging.Abstractions.Publishers; +using Vulthil.Messaging.Transport; + +namespace Vulthil.Messaging.TestHarness; + +/// +/// Helpers for the in-memory receive path: deserializes an envelope payload and builds a live +/// bound to the harness's in-memory publisher and send-endpoint +/// provider, so a consumer's nested publishes and sends flow back through the harness. +/// +internal static class InMemoryContext +{ + public static TMessage Deserialize(IServiceProvider scope, MessageEnvelope envelope) + where TMessage : notnull + { + var options = scope.GetRequiredService().JsonSerializerOptions; + return envelope.Message.Deserialize(options) + ?? throw new InvalidOperationException($"The in-memory transport could not deserialize a '{envelope.MessageType}' payload."); + } + + public static MessageContext Create(IServiceProvider scope, TMessage message, MessageEnvelope envelope, CancellationToken cancellationToken) + where TMessage : notnull + => MessageContext.CreateFromEnvelope( + message, + envelope, + routingKey: string.Empty, + redelivered: false, + retryCount: 0, + replyToFallback: null, + scope.GetRequiredService(), + scope.GetRequiredService(), + cancellationToken); +} diff --git a/src/Vulthil.Messaging.TestHarness/InMemoryHandler.cs b/src/Vulthil.Messaging.TestHarness/InMemoryHandler.cs new file mode 100644 index 0000000..95cc3d3 --- /dev/null +++ b/src/Vulthil.Messaging.TestHarness/InMemoryHandler.cs @@ -0,0 +1,14 @@ +using Vulthil.Messaging.Transport; + +namespace Vulthil.Messaging.TestHarness; + +/// +/// In-memory dispatch handler stored in a . +/// runs one registered consumer for a delivered message; a one-way consumer returns , +/// a request consumer returns the reply . +/// +/// The consumer contract this handler implements. +/// Resolves the consumer from the scope, builds the context, runs the consume pipeline, and (for requests) returns the reply. +internal sealed record InMemoryHandler( + HandlerKind Kind, + Func> Dispatch); diff --git a/src/Vulthil.Messaging.TestHarness/InMemoryHandlerFactory.cs b/src/Vulthil.Messaging.TestHarness/InMemoryHandlerFactory.cs new file mode 100644 index 0000000..19cddf3 --- /dev/null +++ b/src/Vulthil.Messaging.TestHarness/InMemoryHandlerFactory.cs @@ -0,0 +1,38 @@ +using System.Collections.Concurrent; +using System.Reflection; +using Vulthil.Messaging.Queues; +using Vulthil.Messaging.Transport; + +namespace Vulthil.Messaging.TestHarness; + +/// +/// In-memory implementation of . Binds the open-generic +/// builders to concrete type arguments via cached typed delegates, so the +/// reflection cost is paid once per consumer/message shape — the same pattern the RabbitMQ transport uses. +/// +internal sealed class InMemoryHandlerFactory : IMessageHandlerFactory +{ + private static readonly MethodInfo _consumerMethod = typeof(InMemoryMessageHandlers) + .GetMethod(nameof(InMemoryMessageHandlers.ForConsumer), BindingFlags.Public | BindingFlags.Static) + ?? throw new InvalidOperationException($"{nameof(InMemoryMessageHandlers)}.{nameof(InMemoryMessageHandlers.ForConsumer)} not found."); + private static readonly MethodInfo _requestMethod = typeof(InMemoryMessageHandlers) + .GetMethod(nameof(InMemoryMessageHandlers.ForRequestConsumer), BindingFlags.Public | BindingFlags.Static) + ?? throw new InvalidOperationException($"{nameof(InMemoryMessageHandlers)}.{nameof(InMemoryMessageHandlers.ForRequestConsumer)} not found."); + + private readonly ConcurrentDictionary<(Type Consumer, Type Message), Func> _consumerCache = new(); + private readonly ConcurrentDictionary<(Type Consumer, Type Request, Type Response), Func> _requestCache = new(); + + public HandlerEntry ForConsumer(Type consumerType, Type messageType, RetryPolicyDefinition? retryPolicy) + { + var factory = _consumerCache.GetOrAdd((consumerType, messageType), static key => + _consumerMethod.MakeGenericMethod(key.Consumer, key.Message).CreateDelegate>()); + return new HandlerEntry(factory(), HandlerKind.Consumer); + } + + public HandlerEntry ForRequestConsumer(Type consumerType, Type requestType, Type responseType, RetryPolicyDefinition? retryPolicy) + { + var factory = _requestCache.GetOrAdd((consumerType, requestType, responseType), static key => + _requestMethod.MakeGenericMethod(key.Consumer, key.Request, key.Response).CreateDelegate>()); + return new HandlerEntry(factory(), HandlerKind.RequestConsumer); + } +} diff --git a/src/Vulthil.Messaging.TestHarness/InMemoryMessageHandlers.cs b/src/Vulthil.Messaging.TestHarness/InMemoryMessageHandlers.cs new file mode 100644 index 0000000..801926a --- /dev/null +++ b/src/Vulthil.Messaging.TestHarness/InMemoryMessageHandlers.cs @@ -0,0 +1,76 @@ +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using Vulthil.Messaging.Abstractions.Consumers; +using Vulthil.Messaging.Transport; + +namespace Vulthil.Messaging.TestHarness; + +/// +/// Open-generic builders for in-memory dispatch handlers. The methods are the single source of truth for the +/// dispatch closure shape; binds to them via typed delegates, so signature +/// drift fails at startup. Public on an internal class so reflection binds with BindingFlags.Public. +/// +internal static class InMemoryMessageHandlers +{ + /// Builds a handler for a one-way . + public static InMemoryHandler ForConsumer() + where TConsumer : class, IConsumer + where TMessage : notnull + => new(HandlerKind.Consumer, async (scope, message, envelope, ct) => + { + var consumer = scope.GetRequiredService(); + var harness = scope.GetRequiredService(); + var context = InMemoryContext.Create(scope, (TMessage)message, envelope, ct); + + var pipeline = ConsumePipelineFactory.Build(scope, terminal: c => + { + harness.RecordConsumed((TMessage)message, envelope); + return consumer.ConsumeAsync(c, c.CancellationToken); + }); + + await pipeline(context); + return null; + }); + + /// Builds a handler for a request/reply . + public static InMemoryHandler ForRequestConsumer() + where TConsumer : class, IRequestConsumer + where TRequest : notnull + where TResponse : notnull + => new(HandlerKind.RequestConsumer, async (scope, message, envelope, ct) => + { + var consumer = scope.GetRequiredService(); + var provider = scope.GetRequiredService(); + var harness = scope.GetRequiredService(); + var options = provider.JsonSerializerOptions; + var context = InMemoryContext.Create(scope, (TRequest)message, envelope, ct); + + try + { + TResponse response = default!; + var produced = false; + + var pipeline = ConsumePipelineFactory.Build(scope, terminal: async c => + { + harness.RecordConsumed((TRequest)message, envelope); + response = await consumer.ConsumeAsync(c, c.CancellationToken); + produced = true; + }); + + await pipeline(context); + + return (MessageEnvelope?)(produced + ? InMemoryReply.Build(provider.GetUrn(typeof(TResponse)), JsonSerializer.SerializeToElement(response, options), envelope) + : InMemoryReply.BuildFault( + "Consume pipeline did not produce a response (a filter likely short-circuited the chain).", + typeof(InvalidOperationException).FullName!, + stackTrace: null, + options, + envelope)); + } + catch (Exception ex) + { + return InMemoryReply.BuildFault(ex, options, envelope); + } + }); +} diff --git a/src/Vulthil.Messaging.TestHarness/InMemoryPublisher.cs b/src/Vulthil.Messaging.TestHarness/InMemoryPublisher.cs new file mode 100644 index 0000000..3c183ee --- /dev/null +++ b/src/Vulthil.Messaging.TestHarness/InMemoryPublisher.cs @@ -0,0 +1,42 @@ +using Vulthil.Messaging.Abstractions.Publishers; +using Vulthil.Messaging.Transport; + +namespace Vulthil.Messaging.TestHarness; + +/// In-memory : captures every published message, then dispatches it to consumers in-process. +internal sealed class InMemoryPublisher : IPublisher +{ + private readonly IMessageConfigurationProvider _provider; + private readonly InMemoryTransport _transport; + private readonly TestHarness _harness; + + public InMemoryPublisher(IMessageConfigurationProvider provider, InMemoryTransport transport, TestHarness harness) + { + _provider = provider; + _transport = transport; + _harness = harness; + } + + public Task PublishAsync(TMessage message, CancellationToken cancellationToken) + where TMessage : notnull + => PublishAsync(message, null, cancellationToken); + + public async Task PublishAsync( + TMessage message, + Func? configureContext = null, + CancellationToken cancellationToken = default) + where TMessage : notnull + { + ArgumentNullException.ThrowIfNull(message); + + var context = new PublishContext(); + if (configureContext is not null) + { + await configureContext(context); + } + + var envelope = OutgoingEnvelope.Build(_provider, message, context); + _harness.RecordPublished(message, envelope); + await _transport.DeliverAsync(envelope, cancellationToken); + } +} diff --git a/src/Vulthil.Messaging.TestHarness/InMemoryReply.cs b/src/Vulthil.Messaging.TestHarness/InMemoryReply.cs new file mode 100644 index 0000000..60cd879 --- /dev/null +++ b/src/Vulthil.Messaging.TestHarness/InMemoryReply.cs @@ -0,0 +1,38 @@ +using System.Text.Json; +using Vulthil.Messaging.Transport; + +namespace Vulthil.Messaging.TestHarness; + +/// +/// Builds reply instances for request/reply dispatch, mirroring the wire shape a +/// real transport produces: a success carries the response payload at the response URN, a failure carries an +/// at . +/// +internal static class InMemoryReply +{ + public static MessageEnvelope Build(Uri messageType, JsonElement message, MessageEnvelope requestEnvelope) + => new() + { + MessageId = Guid.CreateVersion7().ToString(), + RequestId = requestEnvelope.RequestId, + CorrelationId = requestEnvelope.CorrelationId, + MessageType = messageType, + Message = message, + SentTime = DateTimeOffset.UtcNow, + }; + + public static MessageEnvelope BuildFault(Exception exception, JsonSerializerOptions options, MessageEnvelope requestEnvelope) + => BuildFault(exception.Message, exception.GetType().FullName ?? "Unknown", exception.StackTrace, options, requestEnvelope); + + public static MessageEnvelope BuildFault(string message, string exceptionType, string? stackTrace, JsonSerializerOptions options, MessageEnvelope requestEnvelope) + { + var fault = new RpcFault + { + Message = message, + ExceptionType = exceptionType, + StackTrace = stackTrace, + FaultedAt = DateTimeOffset.UtcNow, + }; + return Build(RpcFault.UrnUri, JsonSerializer.SerializeToElement(fault, options), requestEnvelope); + } +} diff --git a/src/Vulthil.Messaging.TestHarness/InMemoryRequester.cs b/src/Vulthil.Messaging.TestHarness/InMemoryRequester.cs new file mode 100644 index 0000000..cb3b821 --- /dev/null +++ b/src/Vulthil.Messaging.TestHarness/InMemoryRequester.cs @@ -0,0 +1,82 @@ +using System.Text.Json; +using Vulthil.Messaging.Abstractions.Publishers; +using Vulthil.Messaging.Transport; +using Vulthil.Results; + +namespace Vulthil.Messaging.TestHarness; + +/// +/// In-memory : captures the request, dispatches it to a registered responder or request +/// consumer, and maps the reply envelope to a the same way a broker transport +/// does — the response payload on success, an on failure. +/// +internal sealed class InMemoryRequester : IRequester +{ + private readonly IMessageConfigurationProvider _provider; + private readonly InMemoryTransport _transport; + private readonly TestHarness _harness; + + public InMemoryRequester(IMessageConfigurationProvider provider, InMemoryTransport transport, TestHarness harness) + { + _provider = provider; + _transport = transport; + _harness = harness; + } + + public Task> RequestAsync(TRequest message, CancellationToken cancellationToken) + where TRequest : notnull + where TResponse : notnull + => RequestAsync(message, null, cancellationToken); + + public async Task> RequestAsync( + TRequest message, + Func? configureContext = null, + CancellationToken cancellationToken = default) + where TRequest : notnull + where TResponse : notnull + { + ArgumentNullException.ThrowIfNull(message); + + var context = new RequestContext(); + if (configureContext is not null) + { + await configureContext(context); + } + + var requestId = Guid.CreateVersion7().ToString(); + var envelope = OutgoingEnvelope.Build(_provider, message, context, requestId); + _harness.RecordRequested(message, envelope); + + var reply = await _transport.DeliverRequestAsync(envelope, cancellationToken); + return MapReply(reply); + } + + private Result MapReply(MessageEnvelope? reply) + where TResponse : notnull + { + if (reply is null) + { + return Result.Failure(Error.NotFound( + "Messaging.Request.NoConsumer", + "No request consumer or responder is registered for the request type.")); + } + + var options = _provider.JsonSerializerOptions; + + if (reply.MessageType == _provider.GetUrn(typeof(TResponse))) + { + var value = reply.Message.Deserialize(options); + return value is not null + ? Result.Success(value) + : Result.Failure(Error.Failure("Messaging.Request.Deserialize", "Inner message deserialization failed.")); + } + + if (reply.MessageType == RpcFault.UrnUri) + { + var fault = reply.Message.Deserialize(options); + return Result.Failure(Error.Failure("Messaging.Request.Failure", fault?.Message ?? "Unknown remote error")); + } + + return Result.Failure(Error.Failure("Messaging.Request.Deserialize", $"Unexpected reply message type '{reply.MessageType}'.")); + } +} diff --git a/src/Vulthil.Messaging.TestHarness/InMemorySendEndpoint.cs b/src/Vulthil.Messaging.TestHarness/InMemorySendEndpoint.cs new file mode 100644 index 0000000..2b63805 --- /dev/null +++ b/src/Vulthil.Messaging.TestHarness/InMemorySendEndpoint.cs @@ -0,0 +1,69 @@ +using System.Collections.Concurrent; +using Vulthil.Messaging.Abstractions.Publishers; +using Vulthil.Messaging.Transport; + +namespace Vulthil.Messaging.TestHarness; + +/// In-memory : hands out in-memory endpoints cached per address. +internal sealed class InMemorySendEndpointProvider : ISendEndpointProvider +{ + private readonly IMessageConfigurationProvider _provider; + private readonly InMemoryTransport _transport; + private readonly TestHarness _harness; + private readonly ConcurrentDictionary _endpoints = new(); + + public InMemorySendEndpointProvider(IMessageConfigurationProvider provider, InMemoryTransport transport, TestHarness harness) + { + _provider = provider; + _transport = transport; + _harness = harness; + } + + public ValueTask GetSendEndpointAsync(Uri address, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(address); + var endpoint = _endpoints.GetOrAdd(address, a => new InMemorySendEndpoint(a, _provider, _transport, _harness)); + return ValueTask.FromResult(endpoint); + } +} + +/// In-memory : captures every sent message, then dispatches it to consumers in-process. +internal sealed class InMemorySendEndpoint : ISendEndpoint +{ + private readonly IMessageConfigurationProvider _provider; + private readonly InMemoryTransport _transport; + private readonly TestHarness _harness; + + public InMemorySendEndpoint(Uri address, IMessageConfigurationProvider provider, InMemoryTransport transport, TestHarness harness) + { + Address = address; + _provider = provider; + _transport = transport; + _harness = harness; + } + + public Uri Address { get; } + + public Task SendAsync(TMessage message, CancellationToken cancellationToken) + where TMessage : notnull + => SendAsync(message, null, cancellationToken); + + public async Task SendAsync( + TMessage message, + Func? configureContext = null, + CancellationToken cancellationToken = default) + where TMessage : notnull + { + ArgumentNullException.ThrowIfNull(message); + + var context = new PublishContext(); + if (configureContext is not null) + { + await configureContext(context); + } + + var envelope = OutgoingEnvelope.Build(_provider, message, context); + _harness.RecordSent(message, envelope); + await _transport.DeliverAsync(envelope, cancellationToken); + } +} diff --git a/src/Vulthil.Messaging.TestHarness/InMemoryTransport.cs b/src/Vulthil.Messaging.TestHarness/InMemoryTransport.cs new file mode 100644 index 0000000..a3cf959 --- /dev/null +++ b/src/Vulthil.Messaging.TestHarness/InMemoryTransport.cs @@ -0,0 +1,91 @@ +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using Vulthil.Messaging.Transport; + +namespace Vulthil.Messaging.TestHarness; + +/// +/// In-memory . Assembles the same a +/// broker transport would from the configured queues, then dispatches produced messages to the matching +/// consumers in-process — no broker. Dispatch is synchronous: a publish/send/request completes only after every +/// triggered consumer and stub has run. +/// +internal sealed class InMemoryTransport : ITransport +{ + private readonly IMessageConfigurationProvider _provider; + private readonly IServiceScopeFactory _scopeFactory; + private readonly TestHarness _harness; + private readonly MessageExecutionRegistry _registry; + + public InMemoryTransport(IMessageConfigurationProvider provider, IServiceScopeFactory scopeFactory, TestHarness harness) + { + _provider = provider; + _scopeFactory = scopeFactory; + _harness = harness; + _registry = new MessageExecutionRegistry(provider, new InMemoryHandlerFactory()); + + // The plans are built eagerly from the configured queues so the harness works whether or not the host's + // hosted services are started — a unit test can resolve IPublisher/ITestHarness and assert immediately. + foreach (var queue in provider.QueueDefinitions) + { + _registry.RegisterQueue(queue); + } + } + + public Task StartAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; + + /// + /// Delivers a published or sent message: runs every registered one-way consumer for the message URN, then any + /// ad-hoc stub for that URN, all in one scope. + /// + public async Task DeliverAsync(MessageEnvelope envelope, CancellationToken cancellationToken) + { + await using var scope = _scopeFactory.CreateAsyncScope(); + var serviceProvider = scope.ServiceProvider; + + var plan = _registry.GetPlanByUrn(envelope.MessageType); + if (plan is not null) + { + var message = Deserialize(envelope, plan.MessageType.Type); + foreach (var handler in plan.Handlers.Where(handler => handler.Kind == HandlerKind.Consumer)) + { + await handler.Dispatch(serviceProvider, message, envelope, cancellationToken); + } + } + + foreach (var stub in _harness.HandlersFor(envelope.MessageType)) + { + await stub(serviceProvider, envelope, cancellationToken); + } + } + + /// + /// Delivers a request and returns the reply envelope: a registered + /// responder takes precedence, then a real request consumer; returns when neither exists. + /// + public async Task DeliverRequestAsync(MessageEnvelope envelope, CancellationToken cancellationToken) + { + await using var scope = _scopeFactory.CreateAsyncScope(); + var serviceProvider = scope.ServiceProvider; + + var responder = _harness.ResponderFor(envelope.MessageType); + if (responder is not null) + { + return responder(serviceProvider, envelope, cancellationToken); + } + + var plan = _registry.GetPlanByUrn(envelope.MessageType); + var handler = plan?.Handlers.FirstOrDefault(h => h.Kind == HandlerKind.RequestConsumer); + if (plan is null || handler is null) + { + return null; + } + + var message = Deserialize(envelope, plan.MessageType.Type); + return await handler.Dispatch(serviceProvider, message, envelope, cancellationToken); + } + + private object Deserialize(MessageEnvelope envelope, Type messageType) + => envelope.Message.Deserialize(messageType, _provider.JsonSerializerOptions) + ?? throw new InvalidOperationException($"The in-memory transport could not deserialize a '{envelope.MessageType}' payload."); +} diff --git a/src/Vulthil.Messaging.TestHarness/OutgoingEnvelope.cs b/src/Vulthil.Messaging.TestHarness/OutgoingEnvelope.cs new file mode 100644 index 0000000..8374661 --- /dev/null +++ b/src/Vulthil.Messaging.TestHarness/OutgoingEnvelope.cs @@ -0,0 +1,28 @@ +using Vulthil.Messaging.Transport; + +namespace Vulthil.Messaging.TestHarness; + +/// +/// Builds the wire for an outgoing message from an already-configured +/// , resolving the correlation id and message id the same way the broker transport +/// does (explicit value, then the configured formatter, then a fresh GUID). +/// +internal static class OutgoingEnvelope +{ + public static MessageEnvelope Build( + IMessageConfigurationProvider provider, + TMessage message, + PublishContext context, + string? requestId = null) + where TMessage : notnull + { + var configuration = provider.GetMessageConfiguration(message.GetType()); + var correlationId = context.CorrelationId + ?? configuration.CorrelationIdFormatter?.Invoke(message) + ?? Guid.CreateVersion7().ToString(); + var messageId = context.MessageId ?? Guid.CreateVersion7().ToString(); + + return MessageEnvelopeFactory.Create( + message, context, messageId, correlationId, configuration.Urn, provider.JsonSerializerOptions, requestId); + } +} diff --git a/src/Vulthil.Messaging.TestHarness/PublicAPI.Unshipped.txt b/src/Vulthil.Messaging.TestHarness/PublicAPI.Unshipped.txt index 7dc5c58..9c76b3b 100644 --- a/src/Vulthil.Messaging.TestHarness/PublicAPI.Unshipped.txt +++ b/src/Vulthil.Messaging.TestHarness/PublicAPI.Unshipped.txt @@ -1 +1,18 @@ #nullable enable +Vulthil.Messaging.TestHarness.CapturedMessage +Vulthil.Messaging.TestHarness.CapturedMessage.CapturedMessage(TMessage Message, Vulthil.Messaging.Transport.MessageEnvelope! Envelope) -> void +Vulthil.Messaging.TestHarness.CapturedMessage.Envelope.get -> Vulthil.Messaging.Transport.MessageEnvelope! +Vulthil.Messaging.TestHarness.CapturedMessage.Envelope.init -> void +Vulthil.Messaging.TestHarness.CapturedMessage.Message.get -> TMessage +Vulthil.Messaging.TestHarness.CapturedMessage.Message.init -> void +Vulthil.Messaging.TestHarness.ITestHarness +Vulthil.Messaging.TestHarness.ITestHarness.Clear() -> void +Vulthil.Messaging.TestHarness.ITestHarness.Consumed() -> System.Collections.Generic.IReadOnlyList!>! +Vulthil.Messaging.TestHarness.ITestHarness.Handle(System.Func!, System.Threading.Tasks.Task!>! handler) -> void +Vulthil.Messaging.TestHarness.ITestHarness.Published() -> System.Collections.Generic.IReadOnlyList!>! +Vulthil.Messaging.TestHarness.ITestHarness.Requested() -> System.Collections.Generic.IReadOnlyList!>! +Vulthil.Messaging.TestHarness.ITestHarness.Respond(System.Func!, TResponse>! responder) -> void +Vulthil.Messaging.TestHarness.ITestHarness.Sent() -> System.Collections.Generic.IReadOnlyList!>! +Vulthil.Messaging.TestHarness.TestHarnessExtensions +static Vulthil.Messaging.TestHarness.TestHarnessExtensions.ReplaceTransportWithTestHarness(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! +static Vulthil.Messaging.TestHarness.TestHarnessExtensions.UseTestHarness(this Vulthil.Messaging.IMessagingConfigurator! configurator) -> Vulthil.Messaging.IMessagingConfigurator! diff --git a/src/Vulthil.Messaging.TestHarness/README.md b/src/Vulthil.Messaging.TestHarness/README.md index 715668f..a5c2836 100644 --- a/src/Vulthil.Messaging.TestHarness/README.md +++ b/src/Vulthil.Messaging.TestHarness/README.md @@ -1,11 +1,29 @@ # Vulthil.Messaging.TestHarness -Test utilities for validating messaging flows in integration and component tests. +An in-memory messaging transport for tests: runs your consumers with no broker and captures produced and +consumed messages for assertion. ## Install `dotnet add package Vulthil.Messaging.TestHarness` +## Quick start + +```csharp +builder.AddMessaging(messaging => +{ + messaging.ConfigureQueue("orders", q => q.AddConsumer()); + messaging.UseTestHarness(); // in place of a broker transport +}); + +var harness = host.Services.GetRequiredService(); +harness.Published().ShouldHaveSingleItem(); +harness.Consumed().ShouldHaveSingleItem(); +``` + +For an integration test that keeps the production composition, swap the transport instead: +`services.ReplaceTransportWithTestHarness()`. + ## Docs Usage patterns: https://github.com/Vulthil/Vulthil.SharedKernel/tree/main/docs/articles/packages diff --git a/src/Vulthil.Messaging.TestHarness/TestHarness.cs b/src/Vulthil.Messaging.TestHarness/TestHarness.cs new file mode 100644 index 0000000..5cb7792 --- /dev/null +++ b/src/Vulthil.Messaging.TestHarness/TestHarness.cs @@ -0,0 +1,109 @@ +using System.Collections.Concurrent; +using System.Collections.Immutable; +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using Vulthil.Messaging.Abstractions.Consumers; +using Vulthil.Messaging.Transport; + +namespace Vulthil.Messaging.TestHarness; + +/// Runs a registered ad-hoc stub for a delivered message of a known type. +internal delegate Task AdHocHandler(IServiceProvider scope, MessageEnvelope envelope, CancellationToken cancellationToken); + +/// Runs a registered ad-hoc responder for a delivered request, returning the reply envelope. +internal delegate MessageEnvelope AdHocResponder(IServiceProvider scope, MessageEnvelope envelope, CancellationToken cancellationToken); + +/// +/// Default : a thread-safe in-memory log of produced/consumed messages plus the +/// ad-hoc stubs registered through and . +/// Stubs are keyed by the message wire URN so the in-memory transport can match a delivery without a CLR type. +/// +internal sealed class TestHarness : ITestHarness +{ + private readonly IMessageConfigurationProvider _provider; + + private readonly ConcurrentQueue _published = new(); + private readonly ConcurrentQueue _sent = new(); + private readonly ConcurrentQueue _consumed = new(); + private readonly ConcurrentQueue _requested = new(); + + private readonly ConcurrentDictionary> _handlers = new(); + private readonly ConcurrentDictionary _responders = new(); + + public TestHarness(IMessageConfigurationProvider provider) => _provider = provider; + + public IReadOnlyList> Published() where TMessage : notnull => Project(_published); + public IReadOnlyList> Sent() where TMessage : notnull => Project(_sent); + public IReadOnlyList> Consumed() where TMessage : notnull => Project(_consumed); + public IReadOnlyList> Requested() where TMessage : notnull => Project(_requested); + + public void Handle(Func, Task> handler) where TMessage : notnull + { + ArgumentNullException.ThrowIfNull(handler); + var built = BuildHandler(handler); + var urn = _provider.GetUrn(typeof(TMessage)); + _handlers.AddOrUpdate(urn, _ => [built], (_, existing) => existing.Add(built)); + } + + public void Respond(Func, TResponse> responder) + where TRequest : notnull + where TResponse : notnull + { + ArgumentNullException.ThrowIfNull(responder); + _responders[_provider.GetUrn(typeof(TRequest))] = BuildResponder(responder); + } + + public void Clear() + { + _published.Clear(); + _sent.Clear(); + _consumed.Clear(); + _requested.Clear(); + } + + internal void RecordPublished(object message, MessageEnvelope envelope) => _published.Enqueue(new RecordedMessage(message, envelope)); + internal void RecordSent(object message, MessageEnvelope envelope) => _sent.Enqueue(new RecordedMessage(message, envelope)); + internal void RecordConsumed(object message, MessageEnvelope envelope) => _consumed.Enqueue(new RecordedMessage(message, envelope)); + internal void RecordRequested(object message, MessageEnvelope envelope) => _requested.Enqueue(new RecordedMessage(message, envelope)); + + internal IReadOnlyList HandlersFor(Uri urn) + => _handlers.TryGetValue(urn, out var handlers) ? handlers : []; + + internal AdHocResponder? ResponderFor(Uri urn) => _responders.GetValueOrDefault(urn); + + private static AdHocHandler BuildHandler(Func, Task> handler) where TMessage : notnull + => (scope, envelope, ct) => + { + var message = InMemoryContext.Deserialize(scope, envelope); + var context = InMemoryContext.Create(scope, message, envelope, ct); + return handler(context); + }; + + private static AdHocResponder BuildResponder(Func, TResponse> responder) + where TRequest : notnull + where TResponse : notnull + => (scope, envelope, ct) => + { + var provider = scope.GetRequiredService(); + var options = provider.JsonSerializerOptions; + var request = InMemoryContext.Deserialize(scope, envelope); + var context = InMemoryContext.Create(scope, request, envelope, ct); + + try + { + var response = responder(context); + return InMemoryReply.Build(provider.GetUrn(typeof(TResponse)), JsonSerializer.SerializeToElement(response, options), envelope); + } + catch (Exception ex) + { + return InMemoryReply.BuildFault(ex, options, envelope); + } + }; + + private static List> Project(ConcurrentQueue log) where TMessage : notnull + => log.Where(entry => entry.Message is TMessage) + .Select(entry => new CapturedMessage((TMessage)entry.Message, entry.Envelope)) + .ToList(); + + private sealed record RecordedMessage(object Message, MessageEnvelope Envelope); +} diff --git a/src/Vulthil.Messaging.TestHarness/TestHarnessExtensions.cs b/src/Vulthil.Messaging.TestHarness/TestHarnessExtensions.cs new file mode 100644 index 0000000..60eba7f --- /dev/null +++ b/src/Vulthil.Messaging.TestHarness/TestHarnessExtensions.cs @@ -0,0 +1,57 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Vulthil.Messaging.Abstractions.Publishers; + +namespace Vulthil.Messaging.TestHarness; + +/// +/// Registers the in-memory transport. The harness reuses the messaging configuration +/// (queues, consumers, message settings) that AddMessaging registered, so it mirrors the real topology +/// with no broker. +/// +public static class TestHarnessExtensions +{ + /// + /// Configures the messaging infrastructure to use the in-memory test harness as its transport. Call this in + /// place of a broker transport (e.g. UseRabbitMq) when composing messaging for a test. + /// + /// The messaging configurator. + /// The same configurator, for chaining. + public static IMessagingConfigurator UseTestHarness(this IMessagingConfigurator configurator) + { + ArgumentNullException.ThrowIfNull(configurator); + RegisterTestHarness(configurator.HostApplicationBuilder.Services); + return configurator; + } + + /// + /// Replaces an already-registered transport (e.g. RabbitMQ) with the in-memory test harness, leaving the rest + /// of the application's composition untouched. Call this from a test host's service-configuration hook (for + /// example a WebApplicationFactory) so production code is not modified for tests. The orphaned broker + /// registrations remain but are never resolved, so no broker connection is attempted. + /// + /// The service collection whose transport registrations are replaced. + /// The same service collection, for chaining. + public static IServiceCollection ReplaceTransportWithTestHarness(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + RegisterTestHarness(services); + return services; + } + + private static void RegisterTestHarness(IServiceCollection services) + { + services.RemoveAll(); + services.RemoveAll(); + services.RemoveAll(); + services.RemoveAll(); + + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + } +} diff --git a/src/Vulthil.xUnit/BaseWebApplicationFactory.cs b/src/Vulthil.xUnit/BaseWebApplicationFactory.cs index 0cd3e52..a07dc57 100644 --- a/src/Vulthil.xUnit/BaseWebApplicationFactory.cs +++ b/src/Vulthil.xUnit/BaseWebApplicationFactory.cs @@ -124,6 +124,8 @@ protected override sealed void ConfigureWebHost(IWebHostBuilder builder) builder.UseSetting($"ConnectionStrings:{container.ConnectionStringKey}", connectionString); } + ConfigureCustomWebHost(builder); + builder.ConfigureServices(services => { services.Insert(0, ServiceDescriptor.Singleton( @@ -134,8 +136,6 @@ protected override sealed void ConfigureWebHost(IWebHostBuilder builder) configureHttpClient(services); } }); - - ConfigureCustomWebHost(builder); } /// diff --git a/tests/Directory.Build.props b/tests/Directory.Build.props index 504cfe9..26648d9 100644 --- a/tests/Directory.Build.props +++ b/tests/Directory.Build.props @@ -3,7 +3,7 @@ false - $(NoWarn);CS1591;S125 + $(NoWarn);CS1591;S125;CA1034 diff --git a/tests/Vulthil.Messaging.TestHarness.Tests/TestHarnessTests.cs b/tests/Vulthil.Messaging.TestHarness.Tests/TestHarnessTests.cs new file mode 100644 index 0000000..53216b0 --- /dev/null +++ b/tests/Vulthil.Messaging.TestHarness.Tests/TestHarnessTests.cs @@ -0,0 +1,261 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Vulthil.Messaging; +using Vulthil.Messaging.Abstractions.Consumers; +using Vulthil.Messaging.Abstractions.Publishers; +using Vulthil.Results; +using Vulthil.xUnit; + +namespace Vulthil.Messaging.TestHarness.Tests; + +public sealed class TestHarnessTests : BaseUnitTestCase +{ + private const int ResponderTemperature = 21; + + private readonly IHost _host; + + private ITestHarness Harness => _host.Services.GetRequiredService(); + private IPublisher Publisher => _host.Services.GetRequiredService(); + private IRequester Requester => _host.Services.GetRequiredService(); + private ISendEndpointProvider SendEndpointProvider => _host.Services.GetRequiredService(); + + public TestHarnessTests() + { + var builder = Host.CreateApplicationBuilder(); + builder.AddMessaging(messaging => + { + messaging.ConfigureQueue("orders", queue => queue.AddConsumer()); + messaging.ConfigureQueue("weather-commands", queue => queue.AddConsumer()); + messaging.ConfigureQueue("weather-requests", queue => queue.AddRequestConsumer()); + messaging.ConfigureQueue("exploding", queue => queue.AddRequestConsumer()); + messaging.UseTestHarness(); + }); + _host = builder.Build(); + } + + protected override ValueTask Dispose() + { + _host.Dispose(); + return base.Dispose(); + } + + [Fact] + public async Task PublishingAnEventCapturesItAndRunsTheConsumerIncludingItsNestedPublish() + { + // Arrange + var orderId = Guid.NewGuid(); + + // Act + await Publisher.PublishAsync(new OrderCreated(orderId), CancellationToken); + + // Assert + Harness.Published().ShouldHaveSingleItem().Message.Id.ShouldBe(orderId); + Harness.Consumed().ShouldHaveSingleItem().Message.Id.ShouldBe(orderId); + Harness.Published().ShouldHaveSingleItem().Message.Id.ShouldBe(orderId); + } + + [Fact] + public async Task SendingACommandCapturesItAndRunsTheConsumer() + { + // Arrange + var command = new RecordWeather(Guid.NewGuid(), "Copenhagen"); + var endpoint = await SendEndpointProvider.GetSendEndpointAsync(new Uri("queue:weather-commands"), CancellationToken); + + // Act + await endpoint.SendAsync(command, CancellationToken); + + // Assert + Harness.Sent().ShouldHaveSingleItem().Message.City.ShouldBe("Copenhagen"); + Harness.Consumed().ShouldHaveSingleItem().Message.City.ShouldBe("Copenhagen"); + } + + [Fact] + public async Task RequestingReturnsTheRegisteredRequestConsumersResponse() + { + // Arrange + var request = new GetWeather("Oslo"); + + // Act + var result = await Requester.RequestAsync(request, CancellationToken); + + // Assert + result.IsSuccess.ShouldBeTrue(); + result.Value.City.ShouldBe("Oslo"); + result.Value.TemperatureC.ShouldBe(GetWeatherConsumer.Temperature); + Harness.Requested().ShouldHaveSingleItem().Message.City.ShouldBe("Oslo"); + Harness.Consumed().ShouldHaveSingleItem(); + } + + [Fact] + public async Task RequestingAFaultingConsumerReturnsAFailureResult() + { + // Arrange + var request = new ExplodingRequest("boom"); + + // Act + var result = await Requester.RequestAsync(request, CancellationToken); + + // Assert + result.IsSuccess.ShouldBeFalse(); + result.Error.Code.ShouldBe("Messaging.Request.Failure"); + result.Error.Description.ShouldContain("boom"); + } + + [Fact] + public async Task RequestingWithNoConsumerOrResponderReturnsAFailureResult() + { + // Arrange + var request = new ExternalQuery("unhandled"); + + // Act + var result = await Requester.RequestAsync(request, CancellationToken); + + // Assert + result.IsSuccess.ShouldBeFalse(); + result.Error.Code.ShouldBe("Messaging.Request.NoConsumer"); + } + + [Fact] + public async Task RespondStubAnswersRequestsForAnOtherwiseUnhandledType() + { + // Arrange + Harness.Respond(context => new ExternalReply($"mocked:{context.Message.Query}")); + + // Act + var result = await Requester.RequestAsync(new ExternalQuery("ping"), CancellationToken); + + // Assert + result.IsSuccess.ShouldBeTrue(); + result.Value.Answer.ShouldBe("mocked:ping"); + } + + [Fact] + public async Task RespondStubTakesPrecedenceOverARegisteredRequestConsumer() + { + // Arrange + Harness.Respond(context => new WeatherForecast(context.Message.City, ResponderTemperature)); + + // Act + var result = await Requester.RequestAsync(new GetWeather("Bergen"), CancellationToken); + + // Assert + result.IsSuccess.ShouldBeTrue(); + result.Value.TemperatureC.ShouldBe(ResponderTemperature); + } + + [Fact] + public async Task HandleStubReactsToAPublishedMessage() + { + // Arrange + var shippedIds = new List(); + Harness.Handle(context => + { + shippedIds.Add(context.Message.Id); + return Task.CompletedTask; + }); + var orderId = Guid.NewGuid(); + + // Act — OrderCreatedConsumer publishes OrderShipped, which the stub observes. + await Publisher.PublishAsync(new OrderCreated(orderId), CancellationToken); + + // Assert + shippedIds.ShouldHaveSingleItem().ShouldBe(orderId); + } + + [Fact] + public async Task ClearResetsCapturedMessages() + { + // Arrange + await Publisher.PublishAsync(new OrderCreated(Guid.NewGuid()), CancellationToken); + Harness.Published().ShouldNotBeEmpty(); + + // Act + Harness.Clear(); + + // Assert + Harness.Published().ShouldBeEmpty(); + Harness.Consumed().ShouldBeEmpty(); + Harness.Published().ShouldBeEmpty(); + } + + [Fact] + public async Task ReplaceTransportWithTestHarnessReplacesAnAlreadyRegisteredTransport() + { + // Arrange — a host composed with a stand-in "real" transport, as production code would register. + var builder = Host.CreateApplicationBuilder(); + builder.AddMessaging(messaging => messaging.ConfigureQueue("orders", queue => queue.AddConsumer())); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + + // Act — swap the registered transport for the in-memory harness without touching the composition above. + builder.Services.ReplaceTransportWithTestHarness(); + using var host = builder.Build(); + + var orderId = Guid.NewGuid(); + await host.Services.GetRequiredService().PublishAsync(new OrderCreated(orderId), CancellationToken); + + // Assert — the harness, not the stand-in transport, handled the publish. + var harness = host.Services.GetRequiredService(); + harness.Published().ShouldHaveSingleItem().Message.Id.ShouldBe(orderId); + harness.Consumed().ShouldHaveSingleItem().Message.Id.ShouldBe(orderId); + } + + private sealed class ThrowingTransport : ITransport + { + public Task StartAsync(CancellationToken cancellationToken = default) => throw new NotSupportedException(); + } + + private sealed class ThrowingPublisher : IPublisher + { + public Task PublishAsync(TMessage message, CancellationToken cancellationToken) where TMessage : notnull => throw new NotSupportedException(); + public Task PublishAsync(TMessage message, Func? configureContext = null, CancellationToken cancellationToken = default) where TMessage : notnull => throw new NotSupportedException(); + } + + private sealed class ThrowingSendEndpointProvider : ISendEndpointProvider + { + public ValueTask GetSendEndpointAsync(Uri address, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + } + + private sealed class ThrowingRequester : IRequester + { + public Task> RequestAsync(TRequest message, CancellationToken cancellationToken) where TRequest : notnull where TResponse : notnull => throw new NotSupportedException(); + public Task> RequestAsync(TRequest message, Func? configureContext = null, CancellationToken cancellationToken = default) where TRequest : notnull where TResponse : notnull => throw new NotSupportedException(); + } + + public sealed record OrderCreated(Guid Id); + public sealed record OrderShipped(Guid Id); + public sealed record RecordWeather(Guid Id, string City); + public sealed record GetWeather(string City); + public sealed record WeatherForecast(string City, int TemperatureC); + public sealed record ExplodingRequest(string Value); + public sealed record ExternalQuery(string Query); + public sealed record ExternalReply(string Answer); + + public sealed class OrderCreatedConsumer : IConsumer + { + public Task ConsumeAsync(IMessageContext messageContext, CancellationToken cancellationToken = default) + => messageContext.PublishAsync(new OrderShipped(messageContext.Message.Id)); + } + + public sealed class RecordWeatherConsumer : IConsumer + { + public Task ConsumeAsync(IMessageContext messageContext, CancellationToken cancellationToken = default) + => Task.CompletedTask; + } + + public sealed class GetWeatherConsumer : IRequestConsumer + { + public const int Temperature = 99; + + public Task ConsumeAsync(IMessageContext messageContext, CancellationToken cancellationToken = default) + => Task.FromResult(new WeatherForecast(messageContext.Message.City, Temperature)); + } + + public sealed class ExplodingConsumer : IRequestConsumer + { + public Task ConsumeAsync(IMessageContext messageContext, CancellationToken cancellationToken = default) + => throw new InvalidOperationException(messageContext.Message.Value); + } +} diff --git a/tests/Vulthil.Messaging.TestHarness.Tests/Vulthil.Messaging.TestHarness.Tests.csproj b/tests/Vulthil.Messaging.TestHarness.Tests/Vulthil.Messaging.TestHarness.Tests.csproj new file mode 100644 index 0000000..79f9216 --- /dev/null +++ b/tests/Vulthil.Messaging.TestHarness.Tests/Vulthil.Messaging.TestHarness.Tests.csproj @@ -0,0 +1,9 @@ + + + + + + + + + From e959ffe6da53420b2bdba1c3b4af9344b455967e Mon Sep 17 00:00:00 2001 From: Vulthil Date: Fri, 5 Jun 2026 21:18:35 +0200 Subject: [PATCH 38/42] fix(messaging): round-trip queue: URIs through PublishContext address getters Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Transport/PublishContext.cs | 18 +++- .../Transport/PublishContextTests.cs | 88 +++++++++++++++++++ 2 files changed, 102 insertions(+), 4 deletions(-) create mode 100644 tests/Vulthil.Messaging.Tests/Transport/PublishContextTests.cs diff --git a/src/Vulthil.Messaging/Transport/PublishContext.cs b/src/Vulthil.Messaging/Transport/PublishContext.cs index 6244296..3c45909 100644 --- a/src/Vulthil.Messaging/Transport/PublishContext.cs +++ b/src/Vulthil.Messaging/Transport/PublishContext.cs @@ -25,13 +25,13 @@ public class PublishContext : IPublishContext /// Gets the identifier of the message that initiated this chain, or if none was set. public string? InitiatorId { get => _headers.TryGetValue("InitiatorId", out var value) && value is string initiatorId ? initiatorId : null; private set => _headers["InitiatorId"] = value; } /// Gets or sets the address of the endpoint that produced the message; stamped by the transport. - public Uri? SourceAddress { get => _headers.TryGetValue("SourceAddress", out var value) && value is string sourceAddress ? new Uri(sourceAddress) : null; set => _headers["SourceAddress"] = MapUriToString(value); } + public Uri? SourceAddress { get => MapStringToUri("SourceAddress"); set => _headers["SourceAddress"] = MapUriToString(value); } /// Gets or sets the address of the endpoint the message is sent to; stamped by the transport. - public Uri? DestinationAddress { get => _headers.TryGetValue("DestinationAddress", out var value) && value is string destinationAddress ? new Uri(destinationAddress) : null; set => _headers["DestinationAddress"] = MapUriToString(value); } + public Uri? DestinationAddress { get => MapStringToUri("DestinationAddress"); set => _headers["DestinationAddress"] = MapUriToString(value); } /// Gets the address where replies should be sent, or if none was set. - public Uri? ResponseAddress { get => _headers.TryGetValue("ResponseAddress", out var value) && value is string responseAddress ? new Uri(responseAddress) : null; private set => _headers["ResponseAddress"] = MapUriToString(value); } + public Uri? ResponseAddress { get => MapStringToUri("ResponseAddress"); private set => _headers["ResponseAddress"] = MapUriToString(value); } /// Gets the address where fault notifications should be sent, or if none was set. - public Uri? FaultAddress { get => _headers.TryGetValue("FaultAddress", out var value) && value is string faultAddress ? new Uri(faultAddress) : null; private set => _headers["FaultAddress"] = MapUriToString(value); } + public Uri? FaultAddress { get => MapStringToUri("FaultAddress"); private set => _headers["FaultAddress"] = MapUriToString(value); } /// public void AddHeader(string key, object? value) => _headers[key] = value; @@ -67,4 +67,14 @@ public void AddHeaders(IDictionary headers) return uri.Scheme == "queue" ? uri.LocalPath.TrimStart('/') : uri.ToString(); } + + private Uri? MapStringToUri(string key) + { + if (!_headers.TryGetValue(key, out var value) || value is not string stored || string.IsNullOrWhiteSpace(stored)) + { + return null; + } + + return Uri.TryCreate(stored, UriKind.Absolute, out var uri) ? uri : new Uri($"queue:{stored}"); + } } diff --git a/tests/Vulthil.Messaging.Tests/Transport/PublishContextTests.cs b/tests/Vulthil.Messaging.Tests/Transport/PublishContextTests.cs new file mode 100644 index 0000000..ca37d95 --- /dev/null +++ b/tests/Vulthil.Messaging.Tests/Transport/PublishContextTests.cs @@ -0,0 +1,88 @@ +using System.Text.Json; +using Vulthil.Messaging.Transport; +using Vulthil.xUnit; + +namespace Vulthil.Messaging.Tests.Transport; + +public sealed class PublishContextTests : BaseUnitTestCase +{ + private sealed record TestMessage(string Content); + + [Fact] + public void FaultAddressRoundTripsThroughGetterForQueueUri() + { + // Arrange + var address = new Uri("queue:order-faults"); + + // Act + Target.SetFaultAddress(address); + + // Assert + Target.FaultAddress.ShouldBe(address); + } + + [Fact] + public void ResponseAddressRoundTripsThroughGetterForQueueUri() + { + // Arrange + var address = new Uri("queue:order-replies"); + + // Act + Target.SetResponseAddress(address); + + // Assert + Target.ResponseAddress.ShouldBe(address); + } + + [Fact] + public void DestinationAddressRoundTripsThroughGetterForQueueUri() + { + // Arrange + var address = new Uri("queue:orders"); + + // Act + Target.DestinationAddress = address; + + // Assert + Target.DestinationAddress.ShouldBe(address); + } + + [Fact] + public void SourceAddressRoundTripsThroughGetterForAbsoluteAmqpUri() + { + // Arrange + var address = new Uri("amqp://broker/source"); + + // Act + Target.SourceAddress = address; + + // Assert + Target.SourceAddress.ShouldBe(address); + } + + [Fact] + public void FaultAddressIsNullWhenNotSet() + { + // Act & Assert + Target.FaultAddress.ShouldBeNull(); + } + + [Fact] + public void CreateEnvelopeWithQueueFaultAddressDoesNotThrowAndCarriesTheAddress() + { + // Arrange + Target.SetFaultAddress(new Uri("queue:order-faults")); + + // Act + var envelope = MessageEnvelopeFactory.Create( + new TestMessage("payload"), + Target, + messageId: "msg-1", + correlationId: "corr-1", + urn: new Uri("urn:message:TestMessage"), + jsonOptions: JsonSerializerOptions.Default); + + // Assert + envelope.FaultAddress.ShouldBe("queue:order-faults"); + } +} From 2bec04ba12d7fa0b3cdbc9be057a9a3b531de64f Mon Sep 17 00:00:00 2001 From: Vulthil Date: Fri, 5 Jun 2026 22:30:00 +0200 Subject: [PATCH 39/42] test(messaging): add Fault and MessageContextSnapshot tests for Abstractions Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Consumers/FaultTests.cs | 131 ++++++++++++++++++ .../Consumers/MessageContextSnapshotTests.cs | 117 ++++++++++++++++ 2 files changed, 248 insertions(+) create mode 100644 tests/Vulthil.Messaging.Abstractions.Tests/Consumers/FaultTests.cs create mode 100644 tests/Vulthil.Messaging.Abstractions.Tests/Consumers/MessageContextSnapshotTests.cs diff --git a/tests/Vulthil.Messaging.Abstractions.Tests/Consumers/FaultTests.cs b/tests/Vulthil.Messaging.Abstractions.Tests/Consumers/FaultTests.cs new file mode 100644 index 0000000..b0ae50f --- /dev/null +++ b/tests/Vulthil.Messaging.Abstractions.Tests/Consumers/FaultTests.cs @@ -0,0 +1,131 @@ +using System.Text.Json; +using Vulthil.Messaging.Abstractions.Consumers; +using Vulthil.xUnit; + +namespace Vulthil.Messaging.Abstractions.Tests.Consumers; + +public sealed class FaultTests : BaseUnitTestCase +{ + private static JsonSerializerOptions Options => new() { WriteIndented = false }; + + [Fact] + public void FaultShouldExposeSuppliedValues() + { + // Arrange + var faultedAt = DateTimeOffset.FromUnixTimeSeconds(1_700_000_000); + var context = CreateSnapshot(); + + // Act + var fault = new Fault + { + Message = new OrderPlaced("abc", 42), + ExceptionMessage = "boom", + StackTrace = " at Consumer.ConsumeAsync()", + ExceptionType = "System.InvalidOperationException", + FaultedAt = faultedAt, + OriginalContext = context, + }; + + // Assert + fault.Message.ShouldBe(new OrderPlaced("abc", 42)); + fault.ExceptionMessage.ShouldBe("boom"); + fault.StackTrace.ShouldBe(" at Consumer.ConsumeAsync()"); + fault.ExceptionType.ShouldBe("System.InvalidOperationException"); + fault.FaultedAt.ShouldBe(faultedAt); + fault.OriginalContext.ShouldBe(context); + } + + [Fact] + public void FaultShouldAllowNullStackTrace() + { + // Arrange & Act + var fault = CreateFullFault() with { StackTrace = null }; + + // Assert + fault.StackTrace.ShouldBeNull(); + } + + [Fact] + public void FaultsWithSameValuesShouldBeEqual() + { + // Arrange + var first = CreateFullFault(); + var second = CreateFullFault(); + + // Act & Assert + first.ShouldBe(second); + first.GetHashCode().ShouldBe(second.GetHashCode()); + } + + [Fact] + public void FaultsWithDifferentExceptionMessagesShouldNotBeEqual() + { + // Arrange + var first = CreateFullFault(); + var second = CreateFullFault() with { ExceptionMessage = "different" }; + + // Act & Assert + first.ShouldNotBe(second); + } + + [Fact] + public void FaultsWithDifferentOriginalContextShouldNotBeEqual() + { + // Arrange + var first = CreateFullFault(); + var second = CreateFullFault() with { OriginalContext = CreateSnapshot() with { RetryCount = 7 } }; + + // Act & Assert + first.ShouldNotBe(second); + } + + [Fact] + public void WithExpressionShouldProduceModifiedCopyWithoutMutatingOriginal() + { + // Arrange + var original = CreateFullFault(); + + // Act + var modified = original with { ExceptionMessage = "changed" }; + + // Assert + modified.ExceptionMessage.ShouldBe("changed"); + original.ExceptionMessage.ShouldBe("boom"); + } + + [Fact] + public void FaultShouldRoundtripThroughJson() + { + // Arrange + var original = CreateFullFault(); + + // Act + var json = JsonSerializer.SerializeToUtf8Bytes(original, Options); + var roundtripped = JsonSerializer.Deserialize>(json, Options); + + // Assert + roundtripped.ShouldNotBeNull(); + roundtripped.ShouldBe(original); + } + + private static Fault CreateFullFault() => new() + { + Message = new OrderPlaced("abc", 42), + ExceptionMessage = "boom", + StackTrace = " at Consumer.ConsumeAsync()", + ExceptionType = "System.InvalidOperationException", + FaultedAt = DateTimeOffset.FromUnixTimeSeconds(1_700_000_000), + OriginalContext = CreateSnapshot(), + }; + + private static MessageContextSnapshot CreateSnapshot() => new() + { + MessageId = "msg-1", + CorrelationId = "corr-1", + SourceAddress = new Uri("queue:producer"), + RoutingKey = "order.placed", + RetryCount = 3, + }; + + private sealed record OrderPlaced(string OrderId, int Amount); +} diff --git a/tests/Vulthil.Messaging.Abstractions.Tests/Consumers/MessageContextSnapshotTests.cs b/tests/Vulthil.Messaging.Abstractions.Tests/Consumers/MessageContextSnapshotTests.cs new file mode 100644 index 0000000..62d56a7 --- /dev/null +++ b/tests/Vulthil.Messaging.Abstractions.Tests/Consumers/MessageContextSnapshotTests.cs @@ -0,0 +1,117 @@ +using System.Text.Json; +using Vulthil.Messaging.Abstractions.Consumers; +using Vulthil.xUnit; + +namespace Vulthil.Messaging.Abstractions.Tests.Consumers; + +public sealed class MessageContextSnapshotTests : BaseUnitTestCase +{ + private static JsonSerializerOptions Options => new() { WriteIndented = false }; + + [Fact] + public void NewSnapshotShouldDefaultRoutingKeyToEmptyString() + { + // Arrange & Act + var snapshot = new MessageContextSnapshot(); + + // Assert + snapshot.RoutingKey.ShouldBe(string.Empty); + } + + [Fact] + public void NewSnapshotShouldDefaultRetryCountToZero() + { + // Arrange & Act + var snapshot = new MessageContextSnapshot(); + + // Assert + snapshot.RetryCount.ShouldBe(0); + } + + [Fact] + public void NewSnapshotShouldDefaultOptionalMembersToNull() + { + // Arrange & Act + var snapshot = new MessageContextSnapshot(); + + // Assert + snapshot.MessageId.ShouldBeNull(); + snapshot.RequestId.ShouldBeNull(); + snapshot.CorrelationId.ShouldBeNull(); + snapshot.ConversationId.ShouldBeNull(); + snapshot.InitiatorId.ShouldBeNull(); + snapshot.SourceAddress.ShouldBeNull(); + snapshot.DestinationAddress.ShouldBeNull(); + snapshot.ResponseAddress.ShouldBeNull(); + snapshot.FaultAddress.ShouldBeNull(); + } + + [Fact] + public void SnapshotsWithSameValuesShouldBeEqual() + { + // Arrange + var first = CreateFullSnapshot(); + var second = CreateFullSnapshot(); + + // Act & Assert + first.ShouldBe(second); + first.GetHashCode().ShouldBe(second.GetHashCode()); + } + + [Fact] + public void SnapshotsWithDifferentValuesShouldNotBeEqual() + { + // Arrange + var first = CreateFullSnapshot(); + var second = CreateFullSnapshot() with { RetryCount = 99 }; + + // Act & Assert + first.ShouldNotBe(second); + } + + [Fact] + public void WithExpressionShouldProduceModifiedCopyWithoutMutatingOriginal() + { + // Arrange + var original = CreateFullSnapshot(); + + // Act + var modified = original with { RoutingKey = "changed", RetryCount = 5 }; + + // Assert + modified.RoutingKey.ShouldBe("changed"); + modified.RetryCount.ShouldBe(5); + original.RoutingKey.ShouldBe("order.placed"); + original.RetryCount.ShouldBe(3); + } + + [Fact] + public void SnapshotShouldRoundtripThroughJson() + { + // Arrange + var original = CreateFullSnapshot(); + + // Act + var json = JsonSerializer.SerializeToUtf8Bytes(original, Options); + var roundtripped = JsonSerializer.Deserialize(json, Options); + + // Assert + roundtripped.ShouldNotBeNull(); + roundtripped.ShouldBe(original); + } + + private static MessageContextSnapshot CreateFullSnapshot() => new() + { + MessageId = "msg-1", + RequestId = "req-1", + CorrelationId = "corr-1", + ConversationId = "conv-1", + InitiatorId = "init-1", + SourceAddress = new Uri("queue:producer"), + DestinationAddress = new Uri("queue:fulfillment"), + ResponseAddress = new Uri("queue:reply"), + FaultAddress = new Uri("queue:faults"), + RoutingKey = "order.placed", + RetryCount = 3, + }; +} From 06676cd0b0a5f58e1699bd6c46c2df1b4b90bb08 Mon Sep 17 00:00:00 2001 From: Vulthil Date: Fri, 5 Jun 2026 22:39:33 +0200 Subject: [PATCH 40/42] Try updating scripts --- test-reports | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test-reports b/test-reports index c2341c0..bbc10d3 100644 --- a/test-reports +++ b/test-reports @@ -13,7 +13,7 @@ pids=() for project_dir in $test_projects; do ( cd "$project_dir" || exit - dotnet test --collect:"XPlat Code Coverage" + dotnet test -c Release --coverage --coverage-output-format cobertura --coverage-output coverage.cobertura.xml ) & pids+=($!) done From 6aa36b313f4d6a9f9d1e423e7f486f7b7db07a0b Mon Sep 17 00:00:00 2001 From: Vulthil Date: Sat, 6 Jun 2026 21:56:29 +0200 Subject: [PATCH 41/42] test(messaging): pin integration test HttpClients to the http endpoint to avoid CI dev-cert TLS failures Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Vulthil.Messaging.IntegrationTest.Tests/AppHostFixture.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.Tests/AppHostFixture.cs b/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.Tests/AppHostFixture.cs index 8661739..5ba041a 100644 --- a/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.Tests/AppHostFixture.cs +++ b/tests/MessagingIntegrationTests/Vulthil.Messaging.IntegrationTest.Tests/AppHostFixture.cs @@ -27,8 +27,8 @@ public async ValueTask InitializeAsync() await _app.ResourceNotifications.WaitForResourceHealthyAsync("producer", startupCts.Token); await _app.ResourceNotifications.WaitForResourceHealthyAsync("consumer", startupCts.Token); - _producerClient = _app.CreateHttpClient("producer"); - _consumerClient = _app.CreateHttpClient("consumer"); + _producerClient = _app.CreateHttpClient("producer", "http"); + _consumerClient = _app.CreateHttpClient("consumer", "http"); } public async ValueTask DisposeAsync() From e14ff05709f26c6f9b35c3be0ed13048c10e782a Mon Sep 17 00:00:00 2001 From: Vulthil Date: Sat, 6 Jun 2026 21:59:17 +0200 Subject: [PATCH 42/42] added the runner props --- Vulthil.SharedKernel.slnx | 1 + 1 file changed, 1 insertion(+) diff --git a/Vulthil.SharedKernel.slnx b/Vulthil.SharedKernel.slnx index 06a6830..e4fe5fb 100644 --- a/Vulthil.SharedKernel.slnx +++ b/Vulthil.SharedKernel.slnx @@ -103,5 +103,6 @@ +