fix(health): M2.16 review nit — real idempotency guard for SiteEventLog health bridge (#30)

AddSiteEventLogHealthMetricsBridge registered via AddHostedService(factory-lambda),
which sets ImplementationFactory and leaves ImplementationType null. The prior
ImplementationType == guard was therefore silently dead — a second call would spin
up a second SiteEventLogFailureCountReporter. Fix: add a private
SiteEventLogHealthMetricsBridgeMarker singleton and guard on its ServiceType instead.

Also corrects the cycle-path comment in both ServiceCollectionExtensions.cs and
SiteEventLogFailureCountReporter.cs: StoreAndForward.csproj does reference
SiteEventLogging.csproj, so the transitive path HealthMonitoring → StoreAndForward →
SiteEventLogging is real, but adding a direct HealthMonitoring → SiteEventLogging
reference would NOT create a cycle (SiteEventLogging has no back-edge to HealthMonitoring).
The Func<long> seam is a coupling-avoidance measure, not a cycle-breaker.

Adds AddSiteEventLogHealthMetricsBridgeTests.AddSiteEventLogHealthMetricsBridge_IsIdempotent_DoesNotDoubleRegister_HostedService
as a regression test (builds provider and asserts exactly one reporter via GetServices<IHostedService>().OfType<T>()).
This commit is contained in:
Joseph Doherty
2026-06-16 07:22:35 -04:00
parent d81f747434
commit c9244d8bda
4 changed files with 94 additions and 22 deletions
@@ -13,15 +13,17 @@ namespace ZB.MOM.WW.ScadaBridge.HealthMonitoring;
/// <remarks>
/// <para>
/// <b>Why a Func&lt;long&gt; and not ISiteEventLogger directly.</b>
/// <c>HealthMonitoring</c> does not (and cannot) reference
/// <c>SiteEventLogging</c> — <c>HealthMonitoring → StoreAndForward →
/// SiteEventLogging</c> already exists in the transitive graph, so adding a
/// direct reference would create a cycle. The <see cref="Func{TResult}"/>
/// delegate seam breaks the coupling: the caller (Host site wiring) captures
/// <c>ISiteEventLogger.FailedWriteCount</c> as a lambda at registration
/// time, and this service reads only the numeric result. The delegate
/// approach is a standard pattern for counter bridges and keeps the
/// registration path self-documenting.
/// A direct <c>HealthMonitoring → SiteEventLogging</c> reference is avoided
/// to prevent an undesirable low-level coupling: <c>SiteEventLogging</c> is a
/// leaf component that should not pull in higher-level infrastructure. Note that
/// <c>HealthMonitoring → StoreAndForward → SiteEventLogging</c> already
/// exists as a transitive path (confirmed: <c>StoreAndForward.csproj</c> references
/// <c>SiteEventLogging.csproj</c>), so a direct reference would NOT introduce a
/// cycle — the delegate is purely a coupling-avoidance measure. The
/// <see cref="Func{TResult}"/> seam lets the caller (Host site wiring) capture
/// <c>ISiteEventLogger.FailedWriteCount</c> as a lambda at registration time; this
/// service reads only the numeric result. The delegate approach is a standard
/// pattern for counter bridges and keeps the registration path self-documenting.
/// </para>
/// <para>
/// <b>Cadence.</b> 30 s by default — the same cadence as