merge(r2): r2-plan04

This commit is contained in:
Joseph Doherty
2026-07-13 11:09:36 -04:00
26 changed files with 3427 additions and 142 deletions
@@ -109,6 +109,16 @@ public interface IKpiHistoryRepository
string source, string metric, string scope, string? scopeKey,
DateTime fromUtc, DateTime toUtc, CancellationToken cancellationToken = default);
/// <summary>
/// Returns the newest <c>KpiRollupHourly.HourStartUtc</c> across all series, or
/// <c>null</c> when no rollups exist. The recorder's one-shot backfill consults
/// this watermark to skip (or shrink to) the un-rolled tail instead of re-folding
/// the whole raw-retention window on every failover (arch-review 04 round 2, R1).
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task resolving to the newest folded hour start, or <c>null</c> when empty.</returns>
Task<DateTime?> GetLatestRollupHourAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Bulk-deletes hourly rollup rows whose
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Entities.Kpi.KpiRollupHourly.HourStartUtc"/> is
@@ -42,10 +42,10 @@ public enum KpiRollupAggregation
/// Keying by <c>(source, metric)</c> — not metric alone — is deliberate:
/// <c>deliveredLastInterval</c> is emitted by both
/// <see cref="KpiSources.NotificationOutbox"/> and <see cref="KpiSources.SiteCallAudit"/>,
/// so a metric name is not globally unique. Every metric string is taken from the
/// emitting source verbatim (shared <see cref="KpiMetrics"/> constants where they exist,
/// otherwise the source's own private literal), so this catalog stays a faithful mirror
/// of what actually lands in <c>KpiSample.Metric</c>.
/// so a metric name is not globally unique. Every catalogued metric string is now a shared
/// <see cref="KpiMetrics"/> constant — the emitting source consumes the same constant — so an
/// emitter rename is a <strong>compile error</strong>, not a silent Gauge downgrade (R4), and
/// this catalog stays a faithful mirror of what actually lands in <c>KpiSample.Metric</c>.
/// </para>
/// <para>
/// The catalog stores only the <see cref="KpiRollupAggregation.Rate"/> pairs; anything
@@ -57,12 +57,6 @@ public enum KpiRollupAggregation
/// </remarks>
public static class KpiMetricAggregationCatalog
{
// Metric literals for the sources that keep their names private (no shared
// KpiMetrics constant exists for these). Charted metrics use the KpiMetrics catalog.
private const string SiteCallAuditDeliveredLastInterval = "deliveredLastInterval";
private const string SiteHealthAlarmEvalErrors = "alarmEvalErrors";
private const string SiteHealthEventLogWriteFailures = "eventLogWriteFailures";
/// <summary>
/// The <c>(source, metric)</c> pairs classified as <see cref="KpiRollupAggregation.Rate"/>
/// (sum-per-hour). Every other emitted metric is a <see cref="KpiRollupAggregation.Gauge"/>.
@@ -99,11 +93,11 @@ public static class KpiMetricAggregationCatalog
{
(KpiSources.NotificationOutbox, KpiMetrics.NotificationOutbox.DeliveredLastInterval),
(KpiSources.SiteCallAudit, KpiMetrics.SiteCallAudit.FailedLastInterval),
(KpiSources.SiteCallAudit, SiteCallAuditDeliveredLastInterval),
(KpiSources.SiteCallAudit, KpiMetrics.SiteCallAudit.DeliveredLastInterval),
(KpiSources.SiteHealth, KpiMetrics.SiteHealth.ScriptErrors),
(KpiSources.SiteHealth, SiteHealthAlarmEvalErrors),
(KpiSources.SiteHealth, KpiMetrics.SiteHealth.AlarmEvalErrors),
(KpiSources.SiteHealth, KpiMetrics.SiteHealth.DeadLetters),
(KpiSources.SiteHealth, SiteHealthEventLogWriteFailures),
(KpiSources.SiteHealth, KpiMetrics.SiteHealth.EventLogWriteFailures),
};
/// <summary>
@@ -18,11 +18,16 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi;
/// <strong>not</strong> a rename — changing any value would orphan historical samples.
/// </para>
/// <para>
/// Only the metrics a trend page actually charts are catalogued here. Sources may emit
/// additional internal metrics (e.g. the Notification Outbox <c>stuckCount</c> /
/// <c>oldestPendingAgeSeconds</c>, the Site Call Audit <c>deliveredLastInterval</c> /
/// <c>stuck</c> / <c>oldestPendingAgeSeconds</c>, and the bulk of the Site Health catalog)
/// that no page references; those stay private to their source until a page charts them.
/// This catalog now carries two kinds of metric: those a trend page charts, <em>and</em>
/// those the <see cref="KpiMetricAggregationCatalog"/> classifies for the hourly rollup fold
/// (e.g. <c>SiteCallAudit/deliveredLastInterval</c>, <c>SiteHealth/alarmEvalErrors</c>,
/// <c>SiteHealth/eventLogWriteFailures</c>) even if no page charts them yet. The C4/R4 lesson
/// is that <strong>any metric named in two places needs one shared symbol</strong>: a private
/// literal on the emitter plus a matching private literal in the catalog drifts silently on a
/// rename. Sources may still keep genuinely single-use internal metrics (e.g. the Notification
/// Outbox <c>stuckCount</c> / <c>oldestPendingAgeSeconds</c>, the Site Call Audit <c>stuck</c> /
/// <c>oldestPendingAgeSeconds</c>, and the bulk of the Site Health catalog) private until a page
/// or the catalog references them.
/// Constants are grouped by source via nested static classes mirroring
/// <see cref="KpiSources"/>.
/// </para>
@@ -65,6 +70,11 @@ public static class KpiMetrics
/// <summary>Cached calls that failed permanently in the last KPI interval.</summary>
public const string FailedLastInterval = "failedLastInterval";
/// <summary>Cached calls delivered in the last KPI interval. Not charted by a trend
/// page today, but named in the rollup aggregation catalog as a Rate metric — so it
/// is a shared symbol to keep the emitter and catalog in lock-step (R4).</summary>
public const string DeliveredLastInterval = "deliveredLastInterval";
}
/// <summary>
@@ -98,6 +108,16 @@ public static class KpiMetrics
/// <summary>Script-execution error count reported by the site.</summary>
public const string ScriptErrors = "scriptErrors";
/// <summary>Alarm-evaluation error count reported by the site. Named in the rollup
/// aggregation catalog as a Rate metric — shared symbol so an emitter rename is a
/// compile error, not a silent Gauge downgrade (R4).</summary>
public const string AlarmEvalErrors = "alarmEvalErrors";
/// <summary>Event-log write-failure count reported by the site. Named in the rollup
/// aggregation catalog as a Rate metric — shared symbol so an emitter rename is a
/// compile error, not a silent Gauge downgrade (R4).</summary>
public const string EventLogWriteFailures = "eventLogWriteFailures";
/// <summary>Summed store-and-forward buffer depth across all buffers.</summary>
public const string SfBufferDepth = "sfBufferDepth";
}
@@ -3,8 +3,14 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi;
/// <summary>
/// Pure, deterministic downsampling helper for KPI series charting ("KPI History &amp; Trends").
/// Reduces a raw <see cref="KpiSeriesPoint"/> series to at most <c>maxPoints</c> points using
/// last-value-per-bucket / gauge semantics — suitable for step/area charts where the most
/// recent value in a window best represents that window.
/// <em>per-metric</em> bucket reduction driven by <see cref="KpiRollupAggregation"/>:
/// <see cref="KpiRollupAggregation.Gauge"/> (the default) keeps the <strong>last value</strong>
/// in each bucket — suitable for step/area charts where the most recent reading best represents
/// the window; <see cref="KpiRollupAggregation.Rate"/> <strong>sums per bucket</strong> — each
/// raw point of a Rate series is a per-interval delta, so the bucket's true total is the sum of
/// its deltas. Summing on both the raw-minute and hourly-rollup paths charts the same unit
/// ("events per chart bucket") on both sides of the raw/rollup routing boundary, eliminating the
/// ~60× magnitude jump at that boundary (arch-review 04 round 2, R3).
/// </summary>
public static class KpiSeriesBucketer
{
@@ -27,12 +33,20 @@ public static class KpiSeriesBucketer
/// <param name="fromUtc">UTC start of the query window (inclusive).</param>
/// <param name="toUtc">UTC end of the query window (inclusive on the right edge).</param>
/// <param name="maxPoints">Maximum number of output points. Must be ≥ 2.</param>
/// <param name="aggregation">
/// How each bucket is reduced. <see cref="KpiRollupAggregation.Gauge"/> (default) keeps the
/// last value in the bucket — the historical behavior. <see cref="KpiRollupAggregation.Rate"/>
/// sums the values in the bucket — each raw point of a Rate series is a per-interval delta, so
/// the bucket total is the sum of its deltas (arch-review 04 round 2, R3).
/// </param>
/// <returns>
/// An <see cref="IReadOnlyList{T}"/> of at most <paramref name="maxPoints"/> bucketed points,
/// ordered by <see cref="KpiSeriesPoint.BucketStartUtc"/> ascending.
/// Returns <paramref name="raw"/> unchanged (same reference) when
/// <c>raw.Count &lt;= maxPoints</c>; callers must not mutate the underlying
/// collection in that case, as it is the same object passed in.
/// For <see cref="KpiRollupAggregation.Gauge"/> returns <paramref name="raw"/> unchanged
/// (same reference) when <c>raw.Count &lt;= maxPoints</c>; callers must not mutate the
/// underlying collection in that case. <see cref="KpiRollupAggregation.Rate"/> always runs
/// the bucketing pass — two deltas landing in one bucket must sum even in a short series —
/// so it never returns the input reference.
/// </returns>
/// <exception cref="ArgumentOutOfRangeException">
/// Thrown when <paramref name="maxPoints"/> &lt; 2 or
@@ -44,7 +58,8 @@ public static class KpiSeriesBucketer
IReadOnlyList<KpiSeriesPoint> raw,
DateTime fromUtc,
DateTime toUtc,
int maxPoints)
int maxPoints,
KpiRollupAggregation aggregation = KpiRollupAggregation.Gauge)
{
if (maxPoints < 2)
throw new ArgumentOutOfRangeException(nameof(maxPoints),
@@ -54,11 +69,15 @@ public static class KpiSeriesBucketer
throw new ArgumentOutOfRangeException(nameof(toUtc),
toUtc, "toUtc must be strictly greater than fromUtc.");
// Normal runtime case — empty or short series: return as-is.
// Normal runtime case — empty series: return as-is.
if (raw is null || raw.Count == 0)
return Array.Empty<KpiSeriesPoint>();
if (raw.Count <= maxPoints)
// Short-series early return is Gauge-only: for Rate, two deltas landing in one bucket
// must still sum, so a short Rate series cannot be returned verbatim (arch-review 04
// round 2, R3). For well-spaced short Rate series the per-point buckets make the sum an
// identity, so the output still equals the input anyway.
if (aggregation == KpiRollupAggregation.Gauge && raw.Count <= maxPoints)
return raw;
// Divide the window into maxPoints equal-width buckets.
@@ -67,12 +86,11 @@ public static class KpiSeriesBucketer
double windowTicks = (double)(toUtc.Ticks - fromUtc.Ticks);
double bucketWidthTicks = windowTicks / maxPoints;
// For each bucket, track the candidate point: the one with the
// maximum BucketStartUtc (last value within the bucket).
// We use a fixed-size array indexed by bucket number.
// Nullable KpiSeriesPoint[] with 'hasValue' flags is fine since the
// struct is small.
// For each bucket, track the candidate point: for Gauge the one with the maximum
// BucketStartUtc (last value within the bucket); for Rate a running sum of the bucket's
// deltas. Fixed-size arrays indexed by bucket number.
var best = new KpiSeriesPoint[maxPoints];
var sums = new double[maxPoints];
var occupied = new bool[maxPoints];
foreach (var point in raw)
@@ -89,13 +107,19 @@ public static class KpiSeriesBucketer
if (bucketIndex >= maxPoints)
bucketIndex = maxPoints - 1;
if (aggregation == KpiRollupAggregation.Rate)
{
// Rate: accumulate the bucket's per-interval deltas.
sums[bucketIndex] += point.Value;
occupied[bucketIndex] = true;
}
// Keep the last point in iteration order within this bucket. Because the stored
// candidate's BucketStartUtc is the bucket-START timestamp (not the raw point's
// capture time), the comparison below is true for essentially any in-bucket point,
// so each later-in-iteration point overwrites the previous one. For the ascending-
// sorted input this method requires, last-in-iteration IS the largest-timestamp
// point — i.e. last-value-per-bucket semantics.
if (!occupied[bucketIndex] ||
else if (!occupied[bucketIndex] ||
point.BucketStartUtc > best[bucketIndex].BucketStartUtc)
{
best[bucketIndex] = new KpiSeriesPoint(
@@ -109,8 +133,13 @@ public static class KpiSeriesBucketer
var result = new List<KpiSeriesPoint>(maxPoints);
for (int i = 0; i < maxPoints; i++)
{
if (occupied[i])
result.Add(best[i]);
if (!occupied[i])
continue;
result.Add(aggregation == KpiRollupAggregation.Rate
? new KpiSeriesPoint(
fromUtc + TimeSpan.FromTicks((long)(i * bucketWidthTicks)), sums[i])
: best[i]);
}
return result;