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:
@@ -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<KpiHistoryOptions></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
|
||||
|
||||
Reference in New Issue
Block a user