feat(health): wire ISiteEventLogger.FailedWriteCount into SiteHealthReport (#30, M2.16)
Add SiteHealthReport.SiteEventLogWriteFailures (trailing optional long = 0, additive-only), ISiteHealthCollector.SetSiteEventLogWriteFailures (default no-op so existing fakes compile), and SiteEventLogFailureCountReporter (hosted service in HealthMonitoring, Func<long> delegate to avoid the HealthMonitoring → StoreAndForward → SiteEventLogging cycle). Registration helper AddSiteEventLogHealthMetricsBridge added to HealthMonitoring.ServiceCollectionExtensions; wired in SiteServiceRegistration after AddSiteEventLogging. Tests: SiteEventLogWriteFailuresMetricTests (4 collector tests) + SiteEventLogFailureCountReporterTests (2 poller tests) in HealthMonitoring.Tests. 79/79 HealthMonitoring.Tests green, 59/59 SiteEventLogging.Tests green, 0 warnings.
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.HealthMonitoring;
|
||||
|
||||
/// <summary>
|
||||
/// Site Event Logging (#12) M2.16 (#30) — site-side hosted service that
|
||||
/// periodically reads the cumulative event-log write-failure count and pushes
|
||||
/// it into <see cref="ISiteHealthCollector"/> so the next
|
||||
/// <see cref="ISiteHealthCollector.CollectReport"/> emits a fresh
|
||||
/// <c>SiteEventLogWriteFailures</c> field on the site health report.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Why a Func<long> 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Cadence.</b> 30 s by default — the same cadence as
|
||||
/// <c>SiteAuditBacklogReporter</c>, which is coarse enough to stay within
|
||||
/// the health-report interval budget while keeping the central dashboard
|
||||
/// current.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Failure containment.</b> Any unexpected exception during the probe is
|
||||
/// caught and logged; the next tick retries. Mirrors
|
||||
/// <c>SiteAuditBacklogReporter</c>'s "exception logged, not propagated"
|
||||
/// contract.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class SiteEventLogFailureCountReporter : IHostedService, IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Default poll cadence. Matches <c>SiteAuditBacklogReporter.DefaultRefreshInterval</c>
|
||||
/// (30 s) — coarse enough to amortise the read across many reports, fine
|
||||
/// enough that the central dashboard never lags by more than one
|
||||
/// health-report interval.
|
||||
/// </summary>
|
||||
internal static readonly TimeSpan DefaultRefreshInterval = TimeSpan.FromSeconds(30);
|
||||
|
||||
private readonly Func<long> _failedWriteCountProvider;
|
||||
private readonly ISiteHealthCollector _collector;
|
||||
private readonly ILogger<SiteEventLogFailureCountReporter> _logger;
|
||||
private readonly TimeSpan _refreshInterval;
|
||||
private CancellationTokenSource? _cts;
|
||||
private Task? _loop;
|
||||
|
||||
/// <summary>Initializes a new instance of <see cref="SiteEventLogFailureCountReporter"/>.</summary>
|
||||
/// <param name="failedWriteCountProvider">
|
||||
/// A delegate that returns the current cumulative event-log write-failure count.
|
||||
/// Typically wired as <c>() => sp.GetRequiredService<ISiteEventLogger>().FailedWriteCount</c>
|
||||
/// in the Host site composition root.
|
||||
/// </param>
|
||||
/// <param name="collector">The site health collector that receives the failure-count snapshot.</param>
|
||||
/// <param name="logger">Logger instance.</param>
|
||||
/// <param name="refreshInterval">Poll interval override; defaults to <see cref="DefaultRefreshInterval"/> (30 s).</param>
|
||||
public SiteEventLogFailureCountReporter(
|
||||
Func<long> failedWriteCountProvider,
|
||||
ISiteHealthCollector collector,
|
||||
ILogger<SiteEventLogFailureCountReporter> logger,
|
||||
TimeSpan? refreshInterval = null)
|
||||
{
|
||||
_failedWriteCountProvider = failedWriteCountProvider
|
||||
?? throw new ArgumentNullException(nameof(failedWriteCountProvider));
|
||||
_collector = collector ?? throw new ArgumentNullException(nameof(collector));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_refreshInterval = refreshInterval ?? DefaultRefreshInterval;
|
||||
}
|
||||
|
||||
/// <summary>Starts the background polling loop, running an immediate first probe before entering the timed cycle.</summary>
|
||||
/// <param name="ct">Cancellation token signalling host shutdown.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public Task StartAsync(CancellationToken ct)
|
||||
{
|
||||
// Linked CTS lets StopAsync's cancellation AND the host's shutdown
|
||||
// token both terminate the loop; either side firing aborts the
|
||||
// pending Task.Delay.
|
||||
_cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
_loop = Task.Run(() => RunLoopAsync(_cts.Token));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task RunLoopAsync(CancellationToken ct)
|
||||
{
|
||||
// First tick runs immediately so the very first health report after
|
||||
// process start carries a real failure-count snapshot — without this
|
||||
// the dashboard would show 0 for the first 30 s after a deploy even
|
||||
// if failures had already accumulated.
|
||||
SafeProbe();
|
||||
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(_refreshInterval, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
SafeProbe();
|
||||
}
|
||||
}
|
||||
|
||||
private void SafeProbe()
|
||||
{
|
||||
try
|
||||
{
|
||||
var count = _failedWriteCountProvider();
|
||||
_collector.SetSiteEventLogWriteFailures(count);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Catch-all is deliberate: the hosted service must survive every
|
||||
// class of probe failure so the next tick gets a chance. Mirrors
|
||||
// SiteAuditBacklogReporter's "exception logged, not propagated" contract.
|
||||
_logger.LogWarning(ex, "SiteEventLogFailureCountReporter probe failed; next tick will retry.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Signals the polling loop to stop and waits for it to complete.</summary>
|
||||
/// <param name="ct">Cancellation token (not used; the internal CTS governs shutdown).</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public Task StopAsync(CancellationToken ct)
|
||||
{
|
||||
_cts?.Cancel();
|
||||
return _loop ?? Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>Releases the internal <see cref="CancellationTokenSource"/> used to stop the polling loop.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
_cts?.Dispose();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user