fix(kpi-history): backlogTotal trend records the real site-backlog aggregate instead of a hardwired zero

This commit is contained in:
Joseph Doherty
2026-07-09 07:51:55 -04:00
parent 1dd993ec2f
commit 1b53de1933
6 changed files with 271 additions and 3 deletions
@@ -3,6 +3,12 @@ using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Kpi;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi;
// Task 10 (arch-review 04): the append-only repository leaves the snapshot's
// BacklogTotal at zero; the real backlog is the cross-site health aggregate that
// only the central node knows. An optional IAuditBacklogProvider supplies it so
// the *history* sample stops recording a hardwired zero (the live tile already
// filled this in). Sites/tests leave it unregistered → null → snapshot fallback.
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Kpi;
/// <summary>
@@ -44,18 +50,30 @@ public sealed class AuditLogKpiSampleSource : IKpiSampleSource
private const string BacklogTotalMetric = KpiMetrics.AuditLog.BacklogTotal;
private readonly IAuditLogRepository _repository;
private readonly IAuditBacklogProvider? _backlogProvider;
/// <summary>
/// Initializes a new instance of the <see cref="AuditLogKpiSampleSource"/> class.
/// </summary>
/// <param name="repository">
/// Append-only Audit Log repository — its <see cref="IAuditLogRepository.GetKpiSnapshotAsync"/>
/// supplies the volume, error, and backlog counts snapshotted here.
/// supplies the volume and error counts snapshotted here (its <c>BacklogTotal</c>
/// is always zero — see <paramref name="backlogProvider"/>).
/// </param>
public AuditLogKpiSampleSource(IAuditLogRepository repository)
/// <param name="backlogProvider">
/// Optional cross-site backlog aggregate. When registered (central node), the
/// <c>backlogTotal</c> sample records its value; when absent (sites / unit tests,
/// where MS.DI resolves the default) the source falls back to the snapshot's own
/// <c>BacklogTotal</c> — i.e. zero, the pre-Task-10 behavior. Injecting the seam
/// keeps the append-only repo out of the in-memory health aggregator.
/// </param>
public AuditLogKpiSampleSource(
IAuditLogRepository repository,
IAuditBacklogProvider? backlogProvider = null)
{
ArgumentNullException.ThrowIfNull(repository);
_repository = repository;
_backlogProvider = backlogProvider;
}
/// <inheritdoc/>
@@ -78,11 +96,15 @@ public sealed class AuditLogKpiSampleSource : IKpiSampleSource
return [];
}
// Backlog: the provider's cross-site aggregate when registered (central),
// otherwise the snapshot's own value (zero) as the site/test fallback.
var backlogTotal = _backlogProvider?.GetPendingBacklogTotal() ?? snapshot.BacklogTotal;
return
[
Sample(TotalEventsLastHourMetric, snapshot.TotalEventsLastHour, capturedAtUtc),
Sample(ErrorEventsLastHourMetric, snapshot.ErrorEventsLastHour, capturedAtUtc),
Sample(BacklogTotalMetric, snapshot.BacklogTotal, capturedAtUtc),
Sample(BacklogTotalMetric, backlogTotal, capturedAtUtc),
];
}
@@ -0,0 +1,33 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Kpi;
/// <summary>
/// Supplies the system-wide pending Audit Log backlog for the
/// <c>backlogTotal</c> KPI history sample ("KPI History &amp; Trends").
/// </summary>
/// <remarks>
/// <para>
/// The append-only Audit Log repository leaves <c>AuditLogKpiSnapshot.BacklogTotal</c>
/// at zero — the backlog is not a central-table count but the sum of the latest
/// per-site <c>SiteAuditBacklog.PendingCount</c> health reports, which live only in
/// the in-memory central health aggregator. The live Health-dashboard "Audit" tile
/// already fills this in (<c>AuditLogQueryService.GetKpiSnapshotAsync</c>); this seam
/// lets the KPI <em>history</em> sample source read the same aggregate instead of
/// persisting a hardwired zero.
/// </para>
/// <para>
/// Best-effort: sites that have not yet reported (or whose reporter is disabled)
/// contribute zero — a missing snapshot is treated as "unknown, count as nothing",
/// mirroring the live tile. Central-only: registered on the central node where the
/// health aggregator lives; sites and unit tests leave it unregistered so the
/// sample source falls back to the snapshot's own <c>BacklogTotal</c>.
/// </para>
/// </remarks>
public interface IAuditBacklogProvider
{
/// <summary>
/// Returns the current total pending (not-yet-forwarded) Audit Log backlog,
/// summed across every site's latest health report. Never negative; zero when
/// no site is reporting a backlog.
/// </summary>
long GetPendingBacklogTotal();
}
@@ -0,0 +1,52 @@
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Kpi;
namespace ZB.MOM.WW.ScadaBridge.HealthMonitoring.Kpi;
/// <summary>
/// Central <see cref="IAuditBacklogProvider"/> — sums the latest per-site
/// <c>SiteAuditBacklog.PendingCount</c> across the in-memory
/// <see cref="ICentralHealthAggregator"/>, the same aggregate the live
/// Health-dashboard "Audit" backlog tile shows
/// (<c>AuditLogQueryService.GetKpiSnapshotAsync</c>).
/// </summary>
/// <remarks>
/// Registered only on the central node (where the aggregator lives), so the
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Kpi.IKpiSampleSource"/>
/// backing the <c>backlogTotal</c> trend records the real backlog instead of the
/// append-only repository's hardwired zero. Best-effort: sites with no report or
/// a null <c>SiteAuditBacklog</c> contribute nothing.
/// </remarks>
public sealed class CentralHealthAuditBacklogProvider : IAuditBacklogProvider
{
private readonly ICentralHealthAggregator _aggregator;
/// <summary>
/// Initializes a new instance of the <see cref="CentralHealthAuditBacklogProvider"/> class.
/// </summary>
/// <param name="aggregator">The central health aggregator whose per-site latest reports carry the backlog.</param>
public CentralHealthAuditBacklogProvider(ICentralHealthAggregator aggregator)
{
ArgumentNullException.ThrowIfNull(aggregator);
_aggregator = aggregator;
}
/// <inheritdoc />
public long GetPendingBacklogTotal()
{
// Ported verbatim from AuditLogQueryService.GetKpiSnapshotAsync: a null
// SiteAuditBacklog is "unknown" (contributes zero), and the `> 0` guard
// ignores any defensive negative/zero value so the tile and the history
// agree exactly.
long backlog = 0;
foreach (var state in _aggregator.GetAllSiteStates().Values)
{
var pending = state.LatestReport?.SiteAuditBacklog?.PendingCount;
if (pending is > 0)
{
backlog += pending.Value;
}
}
return backlog;
}
}
@@ -63,6 +63,15 @@ public static class ServiceCollectionExtensions
services.AddHostedService(sp => sp.GetRequiredService<CentralHealthAggregator>());
services.AddHostedService<CentralHealthReportLoop>();
// Task 10 (arch-review 04): the central backlog aggregate for the Audit Log
// KPI *history*. Only the central node runs the aggregator, so this seam is
// registered here; the AuditLogKpiSampleSource resolves it (optional ctor
// param) and records the real cross-site backlog instead of the append-only
// repository's hardwired zero. Sites leave it unregistered → snapshot fallback.
services.AddSingleton<
Commons.Interfaces.Kpi.IAuditBacklogProvider,
Kpi.CentralHealthAuditBacklogProvider>();
// "KPI History & Trends": per-site Site Health KPI sample source.
// Reads the in-memory central aggregator (a singleton) rather than a
// repository; registered Scoped to match the recorder's per-tick scope