From 47ba8b147c285d257eb34a1aa54c0200b77558d0 Mon Sep 17 00:00:00 2001 From: "Jeremy D. Miller" Date: Mon, 17 Aug 2026 16:51:54 -0500 Subject: [PATCH] GH-3974: let a consumer ask which message types are handled, and how a batch is shaped Two related gaps, both of which forced consumers to re-derive something Wolverine already knows. 1. "Will this message type have a handler?" could not be asked. Discovery and the static TypeLoadMode registry both materialize AFTER options time, so an extension or app-level convention that installs FALLBACK handlers -- a relay, a batch forwarder, a catch-all -- had to hand-roll a mirror of Wolverine's own discovery convention to avoid clobbering a real handler. Any reflection-based reimplementation of the framework's convention drifts from it, and the drift is silent. The reported case scanned a single assembly, so moving two handlers into a second assembly made them invisible to the mirror and the sweep installed a bare relay OVER a real handler -- the exact defect the guard existed to prevent, with every codegen test still green. Adds WolverineOptions.OnHandlersDiscovered(Action), invoked from HandlerGraph.Compile immediately after Group() -- the earliest point at which the question has a real answer. DiscoveredHandlers exposes Handles() / Handles(Type) and the full resolved set. This is the "hook that runs once discovery has resolved" the issue offers as an acceptable answer to the first half. 2. IMessageBatcher.BatchMessageType is a free-form Type. The default batcher produces T[], but nothing requires that, and the auto-swap in WolverineRuntime.HostService deliberately leaves an application-supplied batcher alone precisely so it can produce its own type. So consumers inferred the handled type from array-ness -- parameters[0].ParameterType.GetElementType() -- which is silently wrong for exactly those batchers. Adds WolverineOptions.TryFindBatchMessageType(elementType, out batchMessageType) and WolverineOptions.BatchMappings. The data already existed on BatchingOptions; it was just internal. The custom-batcher test asserts the mapping reports the batcher's OWN type and that the type is not an array, which is the case an array-ness inference gets wrong. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01JG8Un6iNeyXECKJk3jo5uC --- docs/guide/handlers/batching.md | 21 +++ .../discovering_handled_message_types.cs | 146 ++++++++++++++++++ .../Runtime/Handlers/HandlerGraph.cs | 5 + src/Wolverine/WolverineOptions.Discovery.cs | 123 +++++++++++++++ 4 files changed, 295 insertions(+) create mode 100644 src/Testing/CoreTests/Configuration/discovering_handled_message_types.cs create mode 100644 src/Wolverine/WolverineOptions.Discovery.cs diff --git a/docs/guide/handlers/batching.md b/docs/guide/handlers/batching.md index 43bcd3de9..3b1d37f97 100644 --- a/docs/guide/handlers/batching.md +++ b/docs/guide/handlers/batching.md @@ -621,6 +621,27 @@ public class SubTaskCompletedBatcher : IMessageBatcher snippet source | anchor +::: tip +`BatchMessageType` is a free-form `Type` — a custom batcher does **not** have to produce `T[]`, and Wolverine +deliberately leaves an application-supplied `IMessageBatcher` alone so it can assemble whatever shape it likes. + +That means tooling cannot infer "how is this element type handled?" from the batch handler's parameter being +an array. Ask Wolverine instead: + +```csharp +if (options.TryFindBatchMessageType(typeof(SubTaskCompleted), out var batchMessageType)) +{ + // batchMessageType is SubTaskCompletedBatch here, not SubTaskCompleted[] +} + +// or enumerate every mapping +foreach (var mapping in options.BatchMappings) +{ + Console.WriteLine($"{mapping.ElementType.Name} is handled as {mapping.BatchMessageType.Name}"); +} +``` +::: + And of course, this doesn't work without a matching message handler for our custom message type: diff --git a/src/Testing/CoreTests/Configuration/discovering_handled_message_types.cs b/src/Testing/CoreTests/Configuration/discovering_handled_message_types.cs new file mode 100644 index 000000000..c7484a7b0 --- /dev/null +++ b/src/Testing/CoreTests/Configuration/discovering_handled_message_types.cs @@ -0,0 +1,146 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Shouldly; +using Wolverine; +using Wolverine.Attributes; +using Wolverine.Runtime.Batching; +using Xunit; + +namespace CoreTests.Configuration; + +/// +/// GH-3974. Two related gaps, both of which forced consumers to re-derive something Wolverine already +/// knows: which message types will be handled, and how a batched element type reaches its handler. +/// +public class discovering_handled_message_types +{ + [Fact] + public async Task the_callback_reports_the_resolved_handled_message_types() + { + DiscoveredHandlers? discovered = null; + + using var host = await Host.CreateDefaultBuilder() + .UseWolverine(opts => + { + opts.Discovery.DisableConventionalDiscovery().IncludeType(typeof(DiscoveryProbeHandler)); + opts.OnHandlersDiscovered(x => discovered = x); + }).StartAsync(TestContext.Current.CancellationToken); + + discovered.ShouldNotBeNull("the callback never fired"); + + discovered.Handles().ShouldBeTrue(); + discovered.Handles(typeof(DiscoveryProbeMessage)).ShouldBeTrue(); + + // The whole point: a definitive NO for a type nothing handles, so a fallback can be installed + // without clobbering a real handler + discovered.Handles().ShouldBeFalse(); + + discovered.MessageTypes.ShouldContain(typeof(DiscoveryProbeMessage)); + } + + [Fact] + public async Task the_default_batcher_maps_the_element_type_to_an_array() + { + using var host = await Host.CreateDefaultBuilder() + .UseWolverine(opts => + { + opts.Discovery.DisableConventionalDiscovery().IncludeType(typeof(BatchedProbeHandler)); + opts.BatchMessagesOf(); + }).StartAsync(TestContext.Current.CancellationToken); + + var options = host.Services.GetRequiredService(); + + options.TryFindBatchMessageType(typeof(BatchedProbe), out var batchMessageType).ShouldBeTrue(); + batchMessageType.ShouldBe(typeof(BatchedProbe[])); + + options.BatchMappings.ShouldContain(x => + x.ElementType == typeof(BatchedProbe) && x.BatchMessageType == typeof(BatchedProbe[])); + } + + /// + /// The case that motivated the issue. IMessageBatcher.BatchMessageType is a free-form + /// — nothing requires T[] — so inferring the handled type from array-ness + /// (parameters[0].ParameterType.GetElementType()) is silently wrong for a custom batcher. + /// + [Fact] + public async Task a_custom_batcher_reports_its_own_batch_message_type() + { + using var host = await Host.CreateDefaultBuilder() + .UseWolverine(opts => + { + opts.Discovery.DisableConventionalDiscovery().IncludeType(typeof(CustomBatchProbeHandler)); + opts.BatchMessagesOf(x => x.Batcher = new CustomProbeBatcher()); + }).StartAsync(TestContext.Current.CancellationToken); + + var options = host.Services.GetRequiredService(); + + options.TryFindBatchMessageType(typeof(CustomBatchedProbe), out var batchMessageType).ShouldBeTrue(); + + // NOT CustomBatchedProbe[] -- which is exactly what an array-ness inference would have guessed + batchMessageType.ShouldBe(typeof(CustomProbeBatch)); + batchMessageType.IsArray.ShouldBeFalse(); + } + + [Fact] + public async Task reports_false_for_an_element_type_that_is_not_batched() + { + using var host = await Host.CreateDefaultBuilder() + .UseWolverine(opts => + { + opts.Discovery.DisableConventionalDiscovery().IncludeType(typeof(DiscoveryProbeHandler)); + }).StartAsync(TestContext.Current.CancellationToken); + + var options = host.Services.GetRequiredService(); + + options.TryFindBatchMessageType(typeof(BatchedProbe), out _).ShouldBeFalse(); + options.BatchMappings.ShouldBeEmpty(); + } +} + +public record DiscoveryProbeMessage; + +public record UnhandledProbeMessage; + +[WolverineIgnore] +public static class DiscoveryProbeHandler +{ + public static void Handle(DiscoveryProbeMessage message) + { + } +} + +public record BatchedProbe(string Name); + +[WolverineIgnore] +public static class BatchedProbeHandler +{ + public static void Handle(BatchedProbe[] batch) + { + } +} + +public record CustomBatchedProbe(string Name); + +/// +/// A batch message that is deliberately NOT an array of the element type. +/// +public record CustomProbeBatch(CustomBatchedProbe[] Items); + +[WolverineIgnore] +public static class CustomBatchProbeHandler +{ + public static void Handle(CustomProbeBatch batch) + { + } +} + +public class CustomProbeBatcher : IMessageBatcher +{ + public IEnumerable Group(IReadOnlyList envelopes) + { + var items = envelopes.Select(x => x.Message).OfType().ToArray(); + yield return new Envelope(new CustomProbeBatch(items), envelopes); + } + + public Type BatchMessageType => typeof(CustomProbeBatch); +} diff --git a/src/Wolverine/Runtime/Handlers/HandlerGraph.cs b/src/Wolverine/Runtime/Handlers/HandlerGraph.cs index 173c2d2d1..177ad19ed 100644 --- a/src/Wolverine/Runtime/Handlers/HandlerGraph.cs +++ b/src/Wolverine/Runtime/Handlers/HandlerGraph.cs @@ -362,6 +362,11 @@ internal void Compile(WolverineOptions options, IServiceContainer container) Group(options); + // GH-3974: the earliest point at which "will this message type be handled?" has a real answer. + // Discovery has resolved and the chains are grouped, so a consumer no longer has to hand-roll a + // mirror of Wolverine's own discovery convention to ask it. + options.ApplyHandlerDiscoveryCallbacks(Chains.Select(x => x.MessageType)); + // This was to address the issue with policies not extending to sticky message // handlers IEnumerable explodeChains(HandlerChain chain) diff --git a/src/Wolverine/WolverineOptions.Discovery.cs b/src/Wolverine/WolverineOptions.Discovery.cs new file mode 100644 index 000000000..b013a3357 --- /dev/null +++ b/src/Wolverine/WolverineOptions.Discovery.cs @@ -0,0 +1,123 @@ +using Wolverine.Runtime.Batching; + +namespace Wolverine; + +/// +/// GH-3974. How a batched element type reaches its handler. +/// +/// The message type being batched, i.e. what a producer actually publishes. +/// +/// The message type the assembled batch is handled as, taken from +/// . +/// +/// +/// This exists because BatchMessageType is a free-form . The default batcher +/// produces T[], but nothing requires that, and the auto-swap in WolverineRuntime.HostService +/// deliberately leaves an application-supplied alone precisely so it can +/// produce its own type — a batcher assembling ServiceUpdateBatch(string Id, ServiceUpdates[] Updates) +/// is fully supported. Consumers were left inferring the relationship from array-ness +/// (parameters[0].ParameterType.GetElementType()), which is silently wrong for exactly those +/// batchers. +/// +public sealed record MessageBatchMapping(Type ElementType, Type BatchMessageType); + +/// +/// GH-3974. The message types handler discovery actually resolved, handed to callbacks registered with +/// . +/// +public sealed class DiscoveredHandlers +{ + private readonly HashSet _messageTypes; + + internal DiscoveredHandlers(IEnumerable messageTypes) + { + _messageTypes = messageTypes.ToHashSet(); + } + + /// + /// Every message type that will be handled by this application. + /// + public IReadOnlyCollection MessageTypes => _messageTypes; + + /// + /// Will this message type be handled? Ask this instead of re-implementing Wolverine's discovery + /// convention by reflection — a mirror of the convention drifts, and it drifts silently. + /// + public bool Handles(Type messageType) => _messageTypes.Contains(messageType); + + /// + /// Will this message type be handled? + /// + public bool Handles() => Handles(typeof(T)); +} + +public sealed partial class WolverineOptions +{ + private readonly List> _handlerDiscoveryCallbacks = []; + + /// + /// GH-3974. The element type → batch message type mapping for every BatchMessagesOf definition, + /// so a consumer can discover how a batched message type is actually handled instead of inferring it + /// from the handler parameter being an array. + /// + public IReadOnlyList BatchMappings => + BatchDefinitions.Select(x => new MessageBatchMapping(x.ElementType, x.Batcher.BatchMessageType)).ToList(); + + /// + /// GH-3974. Find the message type that batches of are handled as. + /// + /// + /// Prefer this over assuming T[]. A custom may assemble any type + /// it likes, and Wolverine deliberately does not override one that an application supplied. + /// + public bool TryFindBatchMessageType(Type elementType, out Type batchMessageType) + { + foreach (var definition in BatchDefinitions) + { + if (definition.ElementType == elementType) + { + batchMessageType = definition.Batcher.BatchMessageType; + return true; + } + } + + batchMessageType = null!; + return false; + } + + /// + /// GH-3974. Register a callback that runs once handler discovery has resolved, receiving the message + /// types that will actually be handled. + /// + /// + /// + /// Discovery and the static TypeLoadMode registry both materialize after options time, so + /// "will this message type have a handler?" cannot be answered while + /// is still being configured. Extensions and app-level conventions that install fallback + /// handlers — a relay, a batch forwarder, a catch-all — were therefore hand-rolling a mirror of + /// Wolverine's own discovery convention and asking that. + /// + /// + /// Any reflection-based reimplementation of the framework's convention will drift from it, and the + /// drift is silent: a mirror that scanned only one assembly stopped seeing handlers that moved to a + /// second one, and installed a bare relay over a real handler — the exact defect the guard + /// existed to prevent, with every codegen test still passing. Ask the framework instead. + /// + /// + public void OnHandlersDiscovered(Action callback) + { + if (callback == null) throw new ArgumentNullException(nameof(callback)); + _handlerDiscoveryCallbacks.Add(callback); + } + + internal void ApplyHandlerDiscoveryCallbacks(IEnumerable messageTypes) + { + if (_handlerDiscoveryCallbacks.Count == 0) return; + + var discovered = new DiscoveredHandlers(messageTypes); + foreach (var callback in _handlerDiscoveryCallbacks) + { + callback(discovered); + } + } +}