diff --git a/src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/ServiceCollectionExtensions.cs b/src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/ServiceCollectionExtensions.cs
index 28d49604..54e86fcc 100644
--- a/src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/ServiceCollectionExtensions.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/ServiceCollectionExtensions.cs
@@ -8,6 +8,18 @@ namespace ZB.MOM.WW.ScadaBridge.HealthMonitoring;
public static class ServiceCollectionExtensions
{
+ ///
+ /// Sentinel marker used by to
+ /// implement an idempotency guard. Because the reporter is registered via a
+ /// factory-lambda overload of AddHostedService, its
+ ///
+ /// is — checking it would be a silent no-op. Registering
+ /// this marker as a singleton and guarding on its ServiceType gives a
+ /// reliable, allocation-free sentinel that works regardless of how the hosted
+ /// service was wired.
+ ///
+ private sealed class SiteEventLogHealthMetricsBridgeMarker { }
+
///
/// Register site-side health monitoring services (metric collection + periodic reporting).
/// Call this on site nodes only. For central, call AddCentralHealthAggregation() instead.
@@ -67,19 +79,25 @@ public static class ServiceCollectionExtensions
///
///
/// Why a Func<long> delegate instead of ISiteEventLogger.
- /// HealthMonitoring must not reference SiteEventLogging directly —
- /// the StoreAndForward → SiteEventLogging edge already exists in the
- /// transitive graph, and HealthMonitoring → StoreAndForward is an
- /// existing direct reference; adding HealthMonitoring → SiteEventLogging
- /// would complete a cycle. The delegate seam keeps
- /// the dependency acyclic: the caller (Host site wiring) captures
+ /// A direct HealthMonitoring → SiteEventLogging reference is avoided to
+ /// prevent an undesirable low-level coupling: SiteEventLogging is a
+ /// leaf component that should not pull in higher-level infrastructure. The
+ /// delegate seam keeps the reference one-way and
+ /// loose: the caller (Host site wiring) captures
/// ISiteEventLogger.FailedWriteCount as a lambda and passes it here.
+ /// Note: HealthMonitoring → StoreAndForward → SiteEventLogging already
+ /// exists as a transitive path, so a direct reference would not introduce a
+ /// cycle — the delegate is purely a coupling-avoidance measure.
///
///
- /// Idempotent — a sentinel check on the
- /// hosted-service descriptor
- /// short-circuits subsequent calls so the hosted service is not
- /// double-registered (AddHostedService has no TryAdd variant).
+ /// Idempotent — a singleton
+ /// is used as the sentinel. Because the reporter is registered via a factory-lambda
+ /// overload of AddHostedService, its
+ ///
+ /// is ; checking it would be a silent no-op and a second
+ /// call would spin up a second polling timer. Guarding on the marker's
+ /// ServiceType is always reliable regardless of how the hosted service
+ /// was wired (AddHostedService has no TryAdd variant).
///
///
/// The service collection to register into.
@@ -99,13 +117,16 @@ public static class ServiceCollectionExtensions
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(failedWriteCountProvider);
- // Idempotent guard — mirrors AddAuditLogHealthMetricsBridge's
- // SiteAuditBacklogReporter sentinel check.
- if (services.Any(d => d.ImplementationType == typeof(SiteEventLogFailureCountReporter)))
+ // Idempotent guard — uses the marker type rather than ImplementationType because
+ // AddHostedService(factory-lambda) sets only ImplementationFactory and leaves
+ // ImplementationType null; an ImplementationType == check is a silent no-op for
+ // factory-registered services. The marker singleton's ServiceType is always set.
+ if (services.Any(d => d.ServiceType == typeof(SiteEventLogHealthMetricsBridgeMarker)))
{
return services;
}
+ services.AddSingleton();
services.AddHostedService(sp => new SiteEventLogFailureCountReporter(
failedWriteCountProvider(sp),
sp.GetRequiredService(),
diff --git a/src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/SiteEventLogFailureCountReporter.cs b/src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/SiteEventLogFailureCountReporter.cs
index 2076f7eb..02b50b6c 100644
--- a/src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/SiteEventLogFailureCountReporter.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/SiteEventLogFailureCountReporter.cs
@@ -13,15 +13,17 @@ namespace ZB.MOM.WW.ScadaBridge.HealthMonitoring;
///
///
/// Why a Func<long> and not ISiteEventLogger directly.
-/// HealthMonitoring does not (and cannot) reference
-/// SiteEventLogging — HealthMonitoring → StoreAndForward →
-/// SiteEventLogging already exists in the transitive graph, so adding a
-/// direct reference would create a cycle. The
-/// delegate seam breaks the coupling: the caller (Host site wiring) captures
-/// ISiteEventLogger.FailedWriteCount 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 HealthMonitoring → SiteEventLogging reference is avoided
+/// to prevent an undesirable low-level coupling: SiteEventLogging is a
+/// leaf component that should not pull in higher-level infrastructure. Note that
+/// HealthMonitoring → StoreAndForward → SiteEventLogging already
+/// exists as a transitive path (confirmed: StoreAndForward.csproj references
+/// SiteEventLogging.csproj), so a direct reference would NOT introduce a
+/// cycle — the delegate is purely a coupling-avoidance measure. The
+/// seam lets the caller (Host site wiring) capture
+/// ISiteEventLogger.FailedWriteCount 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.
///
///
/// Cadence. 30 s by default — the same cadence as
diff --git a/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/AddSiteEventLogHealthMetricsBridgeTests.cs b/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/AddSiteEventLogHealthMetricsBridgeTests.cs
new file mode 100644
index 00000000..b52d3020
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/AddSiteEventLogHealthMetricsBridgeTests.cs
@@ -0,0 +1,48 @@
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests;
+
+///
+/// M2.16 (#30) idempotency regression — code-review finding on commit d81f747.
+///
+/// uses a
+/// factory-lambda overload of AddHostedService, which sets only
+/// ImplementationFactory and leaves ImplementationType null. The original
+/// ImplementationType == guard was therefore a silent no-op: a second call would spin
+/// up a second (two timers both polling).
+/// The fix uses a private marker singleton whose ServiceType is always set.
+///
+///
+public class AddSiteEventLogHealthMetricsBridgeTests
+{
+ [Fact]
+ public void AddSiteEventLogHealthMetricsBridge_IsIdempotent_DoesNotDoubleRegister_HostedService()
+ {
+ // M2.16 (#30): calling the bridge method twice must register exactly one
+ // SiteEventLogFailureCountReporter. Without the marker-type guard the
+ // ImplementationType == check was a no-op for factory-lambda registrations,
+ // so the second call would have added a second hosted service (two timers).
+ var services = new ServiceCollection();
+ services.AddSingleton();
+ services.AddSingleton(typeof(ILogger<>), typeof(NullLogger<>));
+ services.AddHealthMonitoring();
+
+ Func> factory = _ => () => 0L;
+
+ services.AddSiteEventLogHealthMetricsBridge(factory);
+ services.AddSiteEventLogHealthMetricsBridge(factory);
+
+ // Count IHostedService descriptors whose factory produces a
+ // SiteEventLogFailureCountReporter. Because it is factory-registered,
+ // ImplementationType is null — we count by resolving and checking type.
+ using var provider = services.BuildServiceProvider();
+ var reporters = provider.GetServices()
+ .OfType()
+ .ToList();
+
+ Assert.Single(reporters);
+ }
+}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests.csproj b/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests.csproj
index f3bf6d48..478dce1f 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests.csproj
+++ b/tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests.csproj
@@ -11,6 +11,7 @@
+