fix(kpihistory)+fix(notificationoutbox)+fix(sitecallaudit)+fix(host): P5-5 — rate-fold interval triple made self-consistent + cross-validated

Defect: the hourly KPI rollup fold (KpiHistoryRepository.FoldHourlyRollupsAsync)
sums every Rate-classified sample recorded in an hour, correct only if each
sample is a non-overlapping delta covering exactly one recorder tick
(KpiHistoryOptions.SampleInterval). That invariant was an unenforced silent
assumption across three independently-owned config keys — SampleInterval,
NotificationOutboxOptions.DeliveredKpiWindow, SiteCallAuditOptions.KpiInterval —
so widening DeliveredKpiWindow to e.g. 15 min would silently over-count the
rollup ~15x.

Robust fix: NotificationOutboxKpiSampleSource and SiteCallAuditKpiSampleSource
now rescale their Rate-classified samples (deliveredLastInterval,
failedLastInterval) by SampleInterval / (DeliveredKpiWindow or KpiInterval)
before recording, so the rollup's per-hour Sum is correct regardless of how
the other two keys are tuned — the live dashboard tile's own cutoff/window is
untouched. Both sources gain a project reference to KpiHistory to read
SampleInterval; no cycle (KpiHistory references only Commons).

Eager cross-validation (defense-in-depth): KpiRateFoldIntervalCrossValidator
(Host, the one composition root referencing all three component projects)
fails boot naming all three config keys when they diverge, protecting the
separate (still-real) invariant that the recorded/charted KPI-history sample
equals the live tile at the same instant — which the rescale trades away
whenever the scale factor isn't 1. Rides KpiHistoryOptions' existing
ValidateOnStart trigger.

Also: NotificationOutboxOptions had no registered validator at all before this
(the gap this task's "whichever options class owns DeliveredKpiWindow" file
scope pointed at) — added NotificationOutboxOptionsValidator mirroring
SiteCallAuditOptionsValidator's positive-duration/count convention, wired with
ValidateOnStart.

Tests: new rescale tests in both sample-source suites prove the fold is
numerically correct at a non-default SampleInterval (30/40 counted over a
15/20-minute window correctly divides down to a 1-minute-tick delta); new
KpiRateFoldIntervalCrossValidatorTests prove divergent config fails with all
three keys named; new NotificationOutboxOptionsValidatorTests. Every new test
proven to fail against the unfixed computation first (temporarily
short-circuited the scale factor / cross-check to the pre-fix behavior,
confirmed failure, restored) — not via git stash (repo-wide, shared across
concurrent worktrees this session) but via file-copy swap.

Full suites green: Host.Tests (527), ConfigurationDatabase.Tests (401),
KpiHistory.Tests (54), NotificationOutbox.Tests (163),
SiteCallAudit.Tests (67).
This commit is contained in:
Joseph Doherty
2026-08-19 16:39:50 -04:00
parent 1cea01d924
commit abbbcd1a8d
13 changed files with 613 additions and 20 deletions
@@ -0,0 +1,87 @@
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Configuration;
using ZB.MOM.WW.ScadaBridge.KpiHistory;
using ZB.MOM.WW.ScadaBridge.NotificationOutbox;
using ZB.MOM.WW.ScadaBridge.SiteCallAudit;
namespace ZB.MOM.WW.ScadaBridge.Host;
/// <summary>
/// Boot-time cross-validation of the "rate-fold interval triple" (arch-review P5-5,
/// Low). <see cref="KpiHistoryOptions.SampleInterval"/>,
/// <see cref="NotificationOutboxOptions.DeliveredKpiWindow"/>, and
/// <see cref="SiteCallAuditOptions.KpiInterval"/> are three independently-owned config
/// keys, bound in three different component projects with no compile-time relationship
/// to one another.
/// </summary>
/// <remarks>
/// <para>
/// <b>What no longer depends on this equality.</b> <c>NotificationOutboxKpiSampleSource</c>
/// and <c>SiteCallAuditKpiSampleSource</c> now rescale their Rate-classified KPI-history
/// samples (deliveredLastInterval, failedLastInterval) by
/// <c>SampleInterval / (DeliveredKpiWindow or KpiInterval)</c> before recording them, so
/// <c>KpiHistoryRepository.FoldHourlyRollupsAsync</c>'s per-hour <c>Sum</c> over those
/// samples is correct for <em>any</em> value of the other two keys — a widened
/// <c>DeliveredKpiWindow</c> can no longer silently over-count the hourly rollup the way
/// it did before P5-5.
/// </para>
/// <para>
/// <b>What still depends on it.</b> The rescale trades exact per-sample fidelity for
/// rollup correctness: whenever the scale factor is not 1, the recorded/charted
/// deliveredLastInterval/failedLastInterval sample no longer numerically equals what the
/// live Health-dashboard tile shows at the same instant (the live tile computation is
/// untouched and still uses the wider/narrower window directly) — a documented invariant
/// both sample sources' XML docs otherwise promise. This validator keeps that promise true
/// by construction: it fails the host at boot, naming all three configuration keys, unless
/// they are all equal (scale factor exactly 1 on both sides).
/// </para>
/// <para>
/// Registered against <c>IValidateOptions&lt;KpiHistoryOptions&gt;</c> — that options type
/// already calls <c>ValidateOnStart()</c> (see <c>KpiHistory.ServiceCollectionExtensions</c>),
/// so this cross-validator rides the same eager trigger without either
/// <see cref="NotificationOutboxOptions"/> or <see cref="SiteCallAuditOptions"/> needing
/// their own <c>ValidateOnStart()</c> call for this specific check to fire. It lives in Host
/// — the one composition root that references all three component projects — rather than in
/// any of the three (which deliberately do not reference each other).
/// </para>
/// </remarks>
public sealed class KpiRateFoldIntervalCrossValidator : OptionsValidatorBase<KpiHistoryOptions>
{
private readonly IOptions<NotificationOutboxOptions> _notificationOutboxOptions;
private readonly IOptions<SiteCallAuditOptions> _siteCallAuditOptions;
/// <summary>
/// Creates the validator with the two sibling options it cross-checks against
/// <see cref="KpiHistoryOptions"/>.
/// </summary>
public KpiRateFoldIntervalCrossValidator(
IOptions<NotificationOutboxOptions> notificationOutboxOptions,
IOptions<SiteCallAuditOptions> siteCallAuditOptions)
{
ArgumentNullException.ThrowIfNull(notificationOutboxOptions);
ArgumentNullException.ThrowIfNull(siteCallAuditOptions);
_notificationOutboxOptions = notificationOutboxOptions;
_siteCallAuditOptions = siteCallAuditOptions;
}
/// <inheritdoc />
protected override void Validate(ValidationBuilder builder, KpiHistoryOptions options)
{
var sampleInterval = options.SampleInterval;
var deliveredKpiWindow = _notificationOutboxOptions.Value.DeliveredKpiWindow;
var kpiInterval = _siteCallAuditOptions.Value.KpiInterval;
builder.RequireThat(
deliveredKpiWindow == sampleInterval && kpiInterval == sampleInterval,
"KPI rate-fold interval mismatch: " +
$"ScadaBridge:KpiHistory:{nameof(KpiHistoryOptions.SampleInterval)} ({sampleInterval}), " +
$"ScadaBridge:NotificationOutbox:{nameof(NotificationOutboxOptions.DeliveredKpiWindow)} " +
$"({deliveredKpiWindow}), and " +
$"ScadaBridge:SiteCallAudit:{nameof(SiteCallAuditOptions.KpiInterval)} ({kpiInterval}) " +
"must all be equal. The KPI-history sample sources rescale their delivered/failed-last-interval " +
"counts onto SampleInterval before recording so the hourly rollup fold's sum stays correct " +
"regardless of this divergence, but a mismatch here still means the recorded/charted KPI-history " +
"values would silently stop matching the live Health-dashboard tile at the same instant.");
}
}
@@ -346,6 +346,12 @@ try
// the KpiHistoryRecorderActor cluster singleton (started in
// AkkaHostedService). Observability/best-effort: NOT readiness-gated.
builder.Services.AddKpiHistory(builder.Configuration);
// P5-5 (arch-review Low): cross-validates the rate-fold interval triple
// (KpiHistoryOptions.SampleInterval, NotificationOutboxOptions.DeliveredKpiWindow,
// SiteCallAuditOptions.KpiInterval) at boot. Rides KpiHistoryOptions' existing
// ValidateOnStart trigger set by AddKpiHistory above.
builder.Services.TryAddEnumerable(
ServiceDescriptor.Singleton<IValidateOptions<KpiHistoryOptions>, KpiRateFoldIntervalCrossValidator>());
// The Audit Log's IKpiSampleSource, enumerated by the recorder registered just
// above. Central-only (its IAuditLogRepository is central-only); split out of
// AddAuditLog by deferred register row 50 for the same reason as