feat(notification-outbox): add AddNotificationOutbox DI registration

This commit is contained in:
Joseph Doherty
2026-05-19 02:07:29 -04:00
parent 517437b0d9
commit 703cb2d392
8 changed files with 218 additions and 45 deletions

View File

@@ -31,7 +31,6 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
private readonly IServiceProvider _serviceProvider;
private readonly NotificationOutboxOptions _options;
private readonly ILogger<NotificationOutboxActor> _logger;
private readonly IReadOnlyDictionary<NotificationType, INotificationDeliveryAdapter> _adapters;
/// <summary>
/// In-flight guard for the dispatch loop. Set true at the start of a sweep and cleared
@@ -46,13 +45,11 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
public NotificationOutboxActor(
IServiceProvider serviceProvider,
NotificationOutboxOptions options,
ILogger<NotificationOutboxActor> logger,
IReadOnlyDictionary<NotificationType, INotificationDeliveryAdapter> adapters)
ILogger<NotificationOutboxActor> logger)
{
_serviceProvider = serviceProvider;
_options = options;
_logger = logger;
_adapters = adapters;
Receive<NotificationSubmit>(HandleSubmit);
Receive<InternalMessages.IngestPersisted>(HandleIngestPersisted);
@@ -174,6 +171,11 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
/// in a try/catch so the returned task never faults — scope creation, service resolution,
/// and retry-policy resolution can all throw, and a faulted task would otherwise leave
/// the dispatcher's in-flight guard stuck and wedge the loop permanently.
///
/// The channel delivery adapters are resolved from the per-sweep scope, not held in a
/// field: <see cref="EmailNotificationDeliveryAdapter"/> takes a scoped
/// <see cref="INotificationRepository"/> directly, so a long-lived adapter reference on
/// this singleton actor would be a captive dependency over a disposed DbContext.
/// </summary>
private async Task RunDispatchPass(DateTimeOffset now)
{
@@ -182,6 +184,7 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
using var scope = _serviceProvider.CreateScope();
var outboxRepository = scope.ServiceProvider.GetRequiredService<INotificationOutboxRepository>();
var notificationRepository = scope.ServiceProvider.GetRequiredService<INotificationRepository>();
var adapters = ResolveAdapters(scope.ServiceProvider);
IReadOnlyList<Notification> due;
try
@@ -205,7 +208,7 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
{
try
{
await DeliverOneAsync(notification, now, maxRetries, retryDelay, outboxRepository);
await DeliverOneAsync(notification, now, maxRetries, retryDelay, outboxRepository, adapters);
}
catch (Exception ex)
{
@@ -238,6 +241,24 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
: (configuration.MaxRetries, configuration.RetryDelay);
}
/// <summary>
/// Builds the <see cref="NotificationType"/> → adapter lookup for a dispatch sweep from
/// the registered <see cref="INotificationDeliveryAdapter"/> services in the supplied
/// scope. The last adapter registered for a given type wins, mirroring DI's last-wins
/// resolution semantics.
/// </summary>
private static IReadOnlyDictionary<NotificationType, INotificationDeliveryAdapter> ResolveAdapters(
IServiceProvider scopedServices)
{
var adapters = new Dictionary<NotificationType, INotificationDeliveryAdapter>();
foreach (var adapter in scopedServices.GetServices<INotificationDeliveryAdapter>())
{
adapters[adapter.Type] = adapter;
}
return adapters;
}
/// <summary>
/// Delivers a single notification through its channel adapter and applies the resulting
/// status transition. A missing adapter parks the notification; otherwise the
@@ -248,9 +269,10 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
DateTimeOffset now,
int maxRetries,
TimeSpan retryDelay,
INotificationOutboxRepository outboxRepository)
INotificationOutboxRepository outboxRepository,
IReadOnlyDictionary<NotificationType, INotificationDeliveryAdapter> adapters)
{
if (!_adapters.TryGetValue(notification.Type, out var adapter))
if (!adapters.TryGetValue(notification.Type, out var adapter))
{
notification.Status = NotificationStatus.Parked;
notification.LastError = $"no delivery adapter for type {notification.Type}";