9e7697359e
Register row 45. NotificationOutboxActor.ResolveAdapters() cached adapter instances in an actor-lifetime IServiceScope (_adaptersScope), but both EmailNotificationDeliveryAdapter and SmsNotificationDeliveryAdapter are AddScoped and hold a scoped INotificationRepository over one EF DbContext. MaxParallelDeliveries (default 4) then drove up to four concurrent deliveries through that single adapter and single context, which central-b logged continuously on the rig as "InvalidOperationException: A second operation was started on this context instance before a previous operation completed". The collided delivery's row is never written and the wasted attempt counts against its retry budget, so sustained contention could park a legitimate notification. The per-delivery scope DeliverGatedAsync already opened for the outbox repository — added for exactly this hazard — did not cover the adapter. _adaptersCache, _adaptersScope and ResolveAdapters() are deleted. Delivery adapters are now resolved inside the per-delivery scope via a static ResolveAdapter(IServiceProvider, NotificationType), preserving DI's last-wins resolution semantics (the enumeration runs to completion rather than short-circuiting) and the null-adapter park path. Nothing shared is lost: the Email adapter builds a fresh SMTP client per attempt from an injected factory and the SMS adapter draws pooled connections from the singleton IHttpClientFactory, so no adapter carried state worth preserving across deliveries. The Central UI's NotificationChannelCatalog is an independent consumer of the same scoped registrations and is untouched, so the adapter-gated Type selector still enumerates exactly the deliverable channels. Tests: Dispatch_ResolvesAdaptersOnce_AcrossMultipleSweeps (which pinned the superseded caching behaviour) is replaced by ParallelDeliveries_EachGetTheirOwnAdapterAndDbContext — two parallel deliveries through a scoped repository whose simulated DbContext throws EF's exact second-operation exception on overlap, with a rendezvous that guarantees the overlap window — and Dispatch_ResolvesAdapterPerDelivery_NotOncePerActorLifetime. Both fail against the pre-fix actor (stash-verified: 1 of 2 rows persisted; 1 distinct adapter instead of 4) and pass after. Parallel-delivery ordering/throughput semantics are unchanged. Docs: Component-NotificationOutbox.md gains the scoped-adapter contract under Delivery Adapters (resolve per delivery; adapters carry no cross-delivery state; genuinely shared resources belong in a singleton/factory), and the NotificationOutbox-006 finding is marked SUPERSEDED with the reasoning. Solution build clean; NotificationOutbox (153), AuditLog (383), integration NotificationOutbox flow (2) and CentralUI notification (14) suites green.
98 lines
5.7 KiB
C#
98 lines
5.7 KiB
C#
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
|
using Microsoft.Extensions.Options;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Kpi;
|
|
using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Delivery;
|
|
using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Kpi;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.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 = "ScadaBridge:NotificationOutbox";
|
|
|
|
/// <summary>Configuration section bound to <see cref="SmsOptions"/>.</summary>
|
|
public const string SmsOptionsSection = "ScadaBridge:Sms";
|
|
|
|
/// <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="ZB.MOM.WW.ScadaBridge.NotificationService"/> SMTP machinery —
|
|
/// <c>Func<ISmtpClientWrapper></c>, <c>OAuth2TokenService</c>,
|
|
/// <c>NotificationOptions</c> and — for configuration rows selecting the EWS transport —
|
|
/// <c>IEwsMailSender</c> with its named HTTP client — 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. Each is an optional constructor parameter on the adapter, so a
|
|
/// host that skips <c>AddNotificationService()</c> still constructs — an EWS row then
|
|
/// fails permanently ("no EWS sender is registered") rather than falling back to SMTP.
|
|
///
|
|
/// <see cref="EmailNotificationDeliveryAdapter"/> is registered <em>scoped</em> because it
|
|
/// takes a scoped <see cref="ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories.INotificationRepository"/>
|
|
/// directly. The <see cref="NotificationOutboxActor"/> resolves the matching adapter from
|
|
/// the fresh DI scope it opens <em>per delivery</em> — not per sweep and never held on the
|
|
/// actor — so no scoped adapter is captured by the singleton actor and no two of the
|
|
/// dispatcher's concurrent deliveries (<c>MaxParallelDeliveries</c>) can land on one
|
|
/// DbContext.
|
|
///
|
|
/// The <see cref="SmsNotificationDeliveryAdapter"/> (Twilio-REST) is registered the same
|
|
/// way alongside the Email adapter, so both resolve from
|
|
/// <c>GetServices<INotificationDeliveryAdapter>()</c>. It also binds + validates
|
|
/// <see cref="SmsOptions"/> (<c>ScadaBridge:Sms</c>) and the named <c>"Twilio"</c>
|
|
/// <see cref="HttpClient"/>; unlike the Email adapter it needs no NotificationService
|
|
/// machinery (a single outbound HTTPS POST per recipient).
|
|
/// </summary>
|
|
/// <param name="services">The DI service collection to register notification outbox services into.</param>
|
|
/// <returns>The same <paramref name="services"/> instance for chaining.</returns>
|
|
public static IServiceCollection AddNotificationOutbox(this IServiceCollection services)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(services);
|
|
|
|
services.AddOptions<NotificationOutboxOptions>()
|
|
.BindConfiguration(OptionsSection);
|
|
|
|
// Bind + validate SmsOptions (ScadaBridge:Sms): the SMS adapter's body cap and
|
|
// per-request timeout fallback. ValidateOnStart surfaces a bad option at host
|
|
// startup rather than at first delivery; the validator is registered via
|
|
// TryAddEnumerable so a re-call of AddNotificationOutbox does not double-register it.
|
|
services.AddOptions<SmsOptions>()
|
|
.BindConfiguration(SmsOptionsSection)
|
|
.ValidateOnStart();
|
|
services.TryAddEnumerable(
|
|
ServiceDescriptor.Singleton<IValidateOptions<SmsOptions>, SmsOptionsValidator>());
|
|
|
|
// Named HTTP client backing the Twilio-REST SMS adapter. Pooled handler lifetime
|
|
// is managed by IHttpClientFactory; the adapter applies the per-request timeout
|
|
// via a linked CTS (so the client's own Timeout stays at its default).
|
|
services.AddHttpClient(SmsNotificationDeliveryAdapter.HttpClientName);
|
|
|
|
// Scoped: the adapters hold a scoped INotificationRepository. Each is registered
|
|
// both under the interface (so the dispatch sweep can enumerate every channel
|
|
// adapter — both Email and SMS must resolve from GetServices<INotificationDeliveryAdapter>())
|
|
// and as the concrete type (so callers and tests can resolve it directly).
|
|
services.AddScoped<EmailNotificationDeliveryAdapter>();
|
|
services.AddScoped<INotificationDeliveryAdapter>(
|
|
sp => sp.GetRequiredService<EmailNotificationDeliveryAdapter>());
|
|
|
|
services.AddScoped<SmsNotificationDeliveryAdapter>();
|
|
services.AddScoped<INotificationDeliveryAdapter>(
|
|
sp => sp.GetRequiredService<SmsNotificationDeliveryAdapter>());
|
|
|
|
// KPI history: the recorder singleton enumerates every IKpiSampleSource each
|
|
// sampling pass to snapshot the outbox delivery KPIs into the central history store.
|
|
// TryAddEnumerable is idempotent — no double-registration if AddNotificationOutbox
|
|
// is ever called more than once, matching the AuditLog idiom.
|
|
services.TryAddEnumerable(ServiceDescriptor.Scoped<IKpiSampleSource, NotificationOutboxKpiSampleSource>());
|
|
|
|
return services;
|
|
}
|
|
}
|