feat(notification-outbox): add AddNotificationOutbox DI registration
This commit is contained in:
@@ -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}";
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ScadaLink.NotificationOutbox.Delivery;
|
||||
|
||||
namespace ScadaLink.NotificationOutbox;
|
||||
|
||||
/// <summary>
|
||||
/// DI registration for the Notification Outbox component: binds
|
||||
/// <see cref="NotificationOutboxOptions"/> and registers the channel delivery adapters.
|
||||
/// </summary>
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>Configuration section bound to <see cref="NotificationOutboxOptions"/>.</summary>
|
||||
public const string OptionsSection = "ScadaLink:NotificationOutbox";
|
||||
|
||||
/// <summary>
|
||||
/// Registers the Notification Outbox services: the <see cref="NotificationOutboxOptions"/>
|
||||
/// binding and the channel delivery adapters.
|
||||
///
|
||||
/// This extension covers only the outbox-specific registrations. The
|
||||
/// <see cref="EmailNotificationDeliveryAdapter"/> reuses the
|
||||
/// <see cref="ScadaLink.NotificationService"/> SMTP machinery —
|
||||
/// <c>Func<ISmtpClientWrapper></c>, <c>OAuth2TokenService</c> and
|
||||
/// <c>NotificationOptions</c> — so the caller (the Host on the central node) must also
|
||||
/// call <c>AddNotificationService()</c>. Re-registering those services here would
|
||||
/// duplicate them; relying on <c>AddNotificationService</c> keeps a single source of truth.
|
||||
///
|
||||
/// <see cref="EmailNotificationDeliveryAdapter"/> is registered <em>scoped</em> because it
|
||||
/// takes a scoped <see cref="ScadaLink.Commons.Interfaces.Repositories.INotificationRepository"/>
|
||||
/// directly. The <see cref="NotificationOutboxActor"/> resolves the adapters from a fresh
|
||||
/// scope per dispatch sweep rather than holding them, so no scoped adapter is captured by
|
||||
/// the singleton actor.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddNotificationOutbox(this IServiceCollection services)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
services.AddOptions<NotificationOutboxOptions>()
|
||||
.BindConfiguration(OptionsSection);
|
||||
|
||||
// Scoped: the adapter holds a scoped INotificationRepository. Registered both under
|
||||
// the interface (so the dispatch sweep can enumerate every channel adapter) and as
|
||||
// the concrete type (so callers and tests can resolve it directly).
|
||||
services.AddScoped<EmailNotificationDeliveryAdapter>();
|
||||
services.AddScoped<INotificationDeliveryAdapter>(
|
||||
sp => sp.GetRequiredService<EmailNotificationDeliveryAdapter>());
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user