fix(outbox): resolve the delivery adapter per delivery, not per actor lifetime

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.
This commit is contained in:
Joseph Doherty
2026-08-15 05:42:24 -04:00
parent 9cf24a4496
commit 9e7697359e
5 changed files with 320 additions and 160 deletions
@@ -37,9 +37,11 @@ public static class ServiceCollectionExtensions
///
/// <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 adapters from a fresh
/// scope per dispatch sweep rather than holding them, so no scoped adapter is captured by
/// the singleton actor.
/// 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