Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions docs/guide/handlers/batching.md
Original file line number Diff line number Diff line change
Expand Up @@ -621,6 +621,27 @@ public class SubTaskCompletedBatcher : IMessageBatcher
<sup><a href='https://github.com/JasperFx/wolverine/blob/main/src/Testing/CoreTests/Acceptance/batch_processing.cs#L189-L220' title='Snippet source file'>snippet source</a> | <a href='#snippet-sample_subtaskcompletedbatcher' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->

::: 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:

<!-- snippet: sample_subtaskcompletedbatchhandler -->
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// </summary>
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<DiscoveryProbeMessage>().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<UnhandledProbeMessage>().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<BatchedProbe>();
}).StartAsync(TestContext.Current.CancellationToken);

var options = host.Services.GetRequiredService<WolverineOptions>();

options.TryFindBatchMessageType(typeof(BatchedProbe), out var batchMessageType).ShouldBeTrue();
batchMessageType.ShouldBe(typeof(BatchedProbe[]));

options.BatchMappings.ShouldContain(x =>
x.ElementType == typeof(BatchedProbe) && x.BatchMessageType == typeof(BatchedProbe[]));
}

/// <summary>
/// The case that motivated the issue. <c>IMessageBatcher.BatchMessageType</c> is a free-form
/// <see cref="Type" /> — nothing requires <c>T[]</c> — so inferring the handled type from array-ness
/// (<c>parameters[0].ParameterType.GetElementType()</c>) is silently wrong for a custom batcher.
/// </summary>
[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<CustomBatchedProbe>(x => x.Batcher = new CustomProbeBatcher());
}).StartAsync(TestContext.Current.CancellationToken);

var options = host.Services.GetRequiredService<WolverineOptions>();

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<WolverineOptions>();

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);

/// <summary>
/// A batch message that is deliberately NOT an array of the element type.
/// </summary>
public record CustomProbeBatch(CustomBatchedProbe[] Items);

[WolverineIgnore]
public static class CustomBatchProbeHandler
{
public static void Handle(CustomProbeBatch batch)
{
}
}

public class CustomProbeBatcher : IMessageBatcher
{
public IEnumerable<Envelope> Group(IReadOnlyList<Envelope> envelopes)
{
var items = envelopes.Select(x => x.Message).OfType<CustomBatchedProbe>().ToArray();
yield return new Envelope(new CustomProbeBatch(items), envelopes);
}

public Type BatchMessageType => typeof(CustomProbeBatch);
}
5 changes: 5 additions & 0 deletions src/Wolverine/Runtime/Handlers/HandlerGraph.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<HandlerChain> explodeChains(HandlerChain chain)
Expand Down
123 changes: 123 additions & 0 deletions src/Wolverine/WolverineOptions.Discovery.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
using Wolverine.Runtime.Batching;

namespace Wolverine;

/// <summary>
/// GH-3974. How a batched element type reaches its handler.
/// </summary>
/// <param name="ElementType">The message type being batched, i.e. what a producer actually publishes.</param>
/// <param name="BatchMessageType">
/// The message type the assembled batch is handled as, taken from
/// <see cref="IMessageBatcher.BatchMessageType" />.
/// </param>
/// <remarks>
/// This exists because <c>BatchMessageType</c> is a free-form <see cref="Type" />. The default batcher
/// produces <c>T[]</c>, but nothing requires that, and the auto-swap in <c>WolverineRuntime.HostService</c>
/// deliberately leaves an application-supplied <see cref="IMessageBatcher" /> alone precisely so it can
/// produce its own type — a batcher assembling <c>ServiceUpdateBatch(string Id, ServiceUpdates[] Updates)</c>
/// is fully supported. Consumers were left inferring the relationship from array-ness
/// (<c>parameters[0].ParameterType.GetElementType()</c>), which is silently wrong for exactly those
/// batchers.
/// </remarks>
public sealed record MessageBatchMapping(Type ElementType, Type BatchMessageType);

/// <summary>
/// GH-3974. The message types handler discovery actually resolved, handed to callbacks registered with
/// <see cref="WolverineOptions.OnHandlersDiscovered" />.
/// </summary>
public sealed class DiscoveredHandlers
{
private readonly HashSet<Type> _messageTypes;

internal DiscoveredHandlers(IEnumerable<Type> messageTypes)
{
_messageTypes = messageTypes.ToHashSet();
}

/// <summary>
/// Every message type that will be handled by this application.
/// </summary>
public IReadOnlyCollection<Type> MessageTypes => _messageTypes;

/// <summary>
/// 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.
/// </summary>
public bool Handles(Type messageType) => _messageTypes.Contains(messageType);

/// <summary>
/// Will this message type be handled?
/// </summary>
public bool Handles<T>() => Handles(typeof(T));
}

public sealed partial class WolverineOptions
{
private readonly List<Action<DiscoveredHandlers>> _handlerDiscoveryCallbacks = [];

/// <summary>
/// GH-3974. The element type → batch message type mapping for every <c>BatchMessagesOf</c> 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.
/// </summary>
public IReadOnlyList<MessageBatchMapping> BatchMappings =>
BatchDefinitions.Select(x => new MessageBatchMapping(x.ElementType, x.Batcher.BatchMessageType)).ToList();

/// <summary>
/// GH-3974. Find the message type that batches of <paramref name="elementType" /> are handled as.
/// </summary>
/// <remarks>
/// Prefer this over assuming <c>T[]</c>. A custom <see cref="IMessageBatcher" /> may assemble any type
/// it likes, and Wolverine deliberately does not override one that an application supplied.
/// </remarks>
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;
}

/// <summary>
/// GH-3974. Register a callback that runs once handler discovery has resolved, receiving the message
/// types that will actually be handled.
/// </summary>
/// <remarks>
/// <para>
/// Discovery and the static <c>TypeLoadMode</c> registry both materialize after options time, so
/// "will this message type have a handler?" cannot be answered while <see cref="WolverineOptions" />
/// is still being configured. Extensions and app-level conventions that install <i>fallback</i>
/// handlers — a relay, a batch forwarder, a catch-all — were therefore hand-rolling a mirror of
/// Wolverine's own discovery convention and asking that.
/// </para>
/// <para>
/// 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 <i>over</i> a real handler — the exact defect the guard
/// existed to prevent, with every codegen test still passing. Ask the framework instead.
/// </para>
/// </remarks>
public void OnHandlersDiscovered(Action<DiscoveredHandlers> callback)
{
if (callback == null) throw new ArgumentNullException(nameof(callback));
_handlerDiscoveryCallbacks.Add(callback);
}

internal void ApplyHandlerDiscoveryCallbacks(IEnumerable<Type> messageTypes)
{
if (_handlerDiscoveryCallbacks.Count == 0) return;

var discovered = new DiscoveredHandlers(messageTypes);
foreach (var callback in _handlerDiscoveryCallbacks)
{
callback(discovered);
}
}
}
Loading