Notification handler called twice for same event
We are using MediatR 12.5.0 with the DryIoc IoC container. We are finding that in some cases, notification handlers are being called twice for the same event.
Here's a simple repro:
App.csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="DryIoc.Microsoft.DependencyInjection" Version="6.2.0" />
<PackageReference Include="MediatR" Version="12.5.0" />
</ItemGroup>
</Project>
Program.cs
using DryIoc;
using DryIoc.Microsoft.DependencyInjection;
using MediatR;
using Microsoft.Extensions.DependencyInjection;
namespace App;
public class E1 : INotification { }
public class E2 : E1 { }
public class C1 : INotificationHandler<E1>
{
public Task Handle(
E1 notification,
CancellationToken cancellationToken)
{
Console.WriteLine($"C1 handling {notification.GetType().Name}");
return Task.CompletedTask;
}
}
public class C2 : INotificationHandler<E2>
{
public Task Handle(
E2 notification,
CancellationToken cancellationToken)
{
Console.WriteLine($"C2 handling {notification.GetType().Name}");
return Task.CompletedTask;
}
}
public class Program
{
private static Rules DefaultRules => Rules.Default
.With(Made.Of(FactoryMethod.ConstructorWithResolvableArguments));
public static void Main(string[] args)
{
var container = new Container(DefaultRules);
var services = new ServiceCollection();
var assembly = typeof(Program).Assembly;
services.AddMediatR(config =>
{
config.RegisterServicesFromAssemblies(assembly);
});
container.Populate(services);
var publisher = container.Resolve<IPublisher>();
publisher.Publish(new E1());
Console.WriteLine("---");
publisher.Publish(new E2());
Console.WriteLine();
Console.Write("Press any key to exit...");
Console.ReadKey();
}
}
When you run this, you'll get the following output:
C1 handling E1
---
C1 handling E2
C1 handling E2
C2 handling E2
Note that C1 handles the E2 event twice. Is this a bug, or by design? It feels odd that defining a notification handler for a derived event (E2) on a type (C2) would cause a notification handler for a base event (E1) on a completely unrelated type (C1) to be called twice.
If you comment out the definition of C2, then C1 only gets called once, and you get the expected output:
C1 handling E1
---
C1 handling E2
For now, we are working around this by de-duping handlers in a custom EventPublisher, but this is obviously not ideal, and it's frankly subject to edge cases where the kludge wouldn't work, e.g., if the same class wanted to handle both a base and derived event separately (not sure if this is supported, or even a good idea, but it's just an example). It might be possible to work around this issue further upstream too by de-duping the ServiceCollection before calling IContainer.Populate, but either way, it's still a hack.
To that last point, the "damage appears to be done" after the IServiceCollection.AddMediatR call, because the returned IServiceCollection contains two ServiceDescriptors for C1 - one for E1, and one for E2:
ServiceType: MediatR.INotificationHandler`1[App.E1] Lifetime: Transient ImplementationType: App.C1
ServiceType: MediatR.INotificationHandler`1[App.E2] Lifetime: Transient ImplementationType: App.C1
ServiceType: MediatR.INotificationHandler`1[App.E2] Lifetime: Transient ImplementationType: App.C2
I believe this rules out the IoC container (DryIoc in my case) as being an influencing factor, as this is all MediatR code before the IoC container is involved; that doesn't happen until the Populate call later.
I see other issues dealing with duplicate notification handling, but I'm not sure that they're the same as this case.
Any guidance would be appreciated. Thanks.
I also found this behavior to be surprising. While some systems are designed to be idempotent and tolerant of duplicate events, this seems unexpected for an in-memory mediator implementation. It appears to be an unintended side effect of how notification handlers are registered when inheritance is involved.
This is a bit strange, I'll take a look!
This code will give you the result you want.
using DryIoc;
using DryIoc.Microsoft.DependencyInjection;
using Microsoft.Extensions.DependencyInjection;
using MediatR;
using System.Reflection;
namespace App
{
//------------------------------
public class ExactTypeOnlyPublisher : INotificationPublisher
{
public Task Publish(IEnumerable<NotificationHandlerExecutor> handlerExecutors, INotification notification, CancellationToken cancellationToken)
{
var notificationType = notification.GetType();
var exactHandlers = handlerExecutors
.Where(x =>
{
var handlerType = x.HandlerInstance.GetType();
var handlerInterfaces = handlerType
.GetInterfaces()
.Where(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(INotificationHandler<>));
return handlerInterfaces.Any(i => i.GenericTypeArguments[0] == notificationType);
});
return Task.WhenAll(exactHandlers.Select(x => x.HandlerCallback(notification, cancellationToken)));
}
}
//------------------------------
public class E1 : INotification { }
public class E2 : E1 { }
public class C1 : INotificationHandler<E1>
{
public Task Handle(E1 notification, CancellationToken cancellationToken)
{
Console.WriteLine($"C1 handling {notification.GetType().Name}");
return Task.CompletedTask;
}
}
public class C2 : INotificationHandler<E2>
{
public Task Handle(E2 notification, CancellationToken cancellationToken)
{
Console.WriteLine($"C2 handling {notification.GetType().Name}");
return Task.CompletedTask;
}
}
class Program
{
private static Rules DefaultRules => Rules.Default.With(Made.Of(FactoryMethod.ConstructorWithResolvableArguments));
static void Main()
{
var container = new Container(DefaultRules);
var services = new ServiceCollection();
var assembly = Assembly.GetExecutingAssembly();
services.AddMediatR(config =>
{
//------------------------------
config.NotificationPublisher = new ExactTypeOnlyPublisher(); // Add
//------------------------------
config.RegisterServicesFromAssembly(assembly);
});
container.Populate(services);
var publisher = container.Resolve<IPublisher>();
Console.WriteLine("Publish E1:");
publisher.Publish(new E1()).Wait();
Console.WriteLine("---");
Console.WriteLine("Publish E2:");
publisher.Publish(new E2()).Wait();
Console.WriteLine("Press any key to exit...");
Console.ReadKey();
}
}
}
The output is;
Publish E1:
C1 handling E1
---
Publish E2:
C2 handling E2
Press any key to exit...
In your code, mediatr registers handlers like this:
Registered: MediatR.INotificationHandler1[App.E2] => App.C1
Registered: MediatR.INotificationHandler1[App.E2] => App.C2
Registered: MediatR.INotificationHandler1[App.E1] => App.C1 `
When E2 is called, C1 is naturally called since E2 : E1.
The ExactTypeOnlyPublisher class is a special publisher that implements the INotificationPublisher interface in MediatR and only runs exact matching notification handlers.
public class ExactTypeOnlyPublisher : INotificationPublisher
{
public Task Publish(IEnumerable<NotificationHandlerExecutor> handlerExecutors, INotification notification, CancellationToken cancellationToken)
{
var notificationType = notification.GetType();
var exactHandlers = handlerExecutors
.Where(x =>
{
var handlerType = x.HandlerInstance.GetType();
var handlerInterfaces = handlerType
.GetInterfaces()
.Where(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(INotificationHandler<>));
return handlerInterfaces.Any(i => i.GenericTypeArguments[0] == notificationType);
});
return Task.WhenAll(exactHandlers.Select(x => x.HandlerCallback(notification, cancellationToken)));
}
}
In the default MediatR behavior, when a notification (e.g., E2) is issued, the handlers for E2 and the handlers written for E2's base class, E1, are also executed. This can sometimes create an undesirable propagation effect. This class, however, only executes the exact same type of handler.
In the meantime, we added our handler to MediatR.
services.AddMediatR(config =>
{
config.NotificationPublisher = new ExactTypeOnlyPublisher();
config.RegisterServicesFromAssembly(assembly);
});
I have resolved it with the below code. I'm using channels for processing the notifications
services.AddMediatR(cfg =>
{
services.AddSingleton<NotificationsQueue>();
services.AddSingleton<INotificationPublisher, ChannelPublisher>();
cfg.NotificationPublisherType = typeof(ChannelPublisher);
RegisterPreHandlers(cfg);
RegisterPostHandlers(cfg);
});
public sealed class ChannelPublisher(NotificationsQueue queue,
ILogger<ChannelPublisher> logger) : INotificationPublisher
{
public async Task Publish(IEnumerable<NotificationHandlerExecutor> handlerExecutors,
INotification notification,
CancellationToken cancellationToken)
{
var distinctHandlers = handlerExecutors
.GroupBy(h => h.HandlerInstance.GetType())
.Select(g => g.First())
.ToArray();
logger.LogInformation("Successfully published notification {NotificationType} to queue.", notification.GetType().Name);
await queue.Writer.WriteAsync(new NotificationEntry(distinctHandlers, notification), cancellationToken);
}
}