feat(kpi): K1 — KpiSample + IKpiSampleSource + IKpiHistoryRepository contracts (Commons)

This commit is contained in:
Joseph Doherty
2026-06-17 19:35:50 -04:00
parent 4c6ae9da0e
commit 460777bffa
7 changed files with 270 additions and 0 deletions
@@ -0,0 +1,55 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Entities.Kpi;
/// <summary>
/// A single point-in-time KPI measurement in the central KPI-history backbone
/// (M6 "KPI History &amp; Trends"). One row per (Source, Metric, Scope, ScopeKey)
/// captured by the recorder singleton at a sampling instant — a tall / EAV row in
/// the central <c>KpiSample</c> table.
/// </summary>
/// <remarks>
/// <para>
/// Persistence-ignorant POCO; the EF Core mapping lives in the Configuration
/// Database component. <see cref="Source"/> draws from
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi.KpiSources"/> and
/// <see cref="Scope"/> from
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi.KpiScopes"/>; <see cref="Metric"/>
/// is drawn from each owning source's own metric catalog.
/// </para>
/// <para>
/// <see cref="Value"/> is a <see cref="double"/> that carries counts exactly and
/// ages as seconds. <see cref="CapturedAtUtc"/> is UTC, like every timestamp in
/// the system.
/// </para>
/// </remarks>
public sealed class KpiSample
{
/// <summary>Surrogate identity key assigned by the store.</summary>
public long Id { get; set; }
/// <summary>
/// Owning component / source — a value from
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi.KpiSources"/>.
/// </summary>
public required string Source { get; set; }
/// <summary>Metric name, drawn from the owning source's metric catalog.</summary>
public required string Metric { get; set; }
/// <summary>
/// Scope discriminator — a value from
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi.KpiScopes"/>.
/// </summary>
public required string Scope { get; set; }
/// <summary>
/// Scope qualifier — the site id or node name the sample belongs to;
/// <c>null</c> for <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi.KpiScopes.Global"/>.
/// </summary>
public string? ScopeKey { get; set; }
/// <summary>Measured value — counts are exact; ages are expressed in seconds.</summary>
public double Value { get; set; }
/// <summary>The UTC instant at which the sample was captured.</summary>
public DateTime CapturedAtUtc { get; set; }
}
@@ -0,0 +1,33 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Kpi;
namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Kpi;
/// <summary>
/// A DI-registered provider of KPI samples (M6 "KPI History &amp; Trends"),
/// implemented by each owning component and enumerated by the central recorder
/// singleton at every sampling interval. The recorder stamps a single
/// <c>capturedAtUtc</c>, fans out to every source's <see cref="CollectAsync"/>,
/// and bulk-writes the combined samples.
/// </summary>
public interface IKpiSampleSource
{
/// <summary>
/// The source this provider reports for — a value from
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi.KpiSources"/>. Every
/// <see cref="KpiSample"/> returned by <see cref="CollectAsync"/> carries this value
/// in <see cref="KpiSample.Source"/>.
/// </summary>
string Source { get; }
/// <summary>
/// Compute this source's KPIs as-of-now, stamping each returned sample's
/// <see cref="KpiSample.CapturedAtUtc"/> with <paramref name="capturedAtUtc"/>.
/// </summary>
/// <param name="capturedAtUtc">The shared UTC capture instant for this sampling pass.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>
/// A task resolving to the samples computed for this source; empty when there is
/// nothing to report.
/// </returns>
Task<IReadOnlyList<KpiSample>> CollectAsync(DateTime capturedAtUtc, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,51 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Kpi;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi;
namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
/// <summary>
/// Data access for the central KPI-history backbone (M6 "KPI History &amp; Trends")
/// — the tall / EAV <c>KpiSample</c> table in central MS SQL. Backs the recorder
/// singleton's bulk write, the bucketed query path that feeds the reusable trend
/// chart, and the retention purge. Implementation lives in the Configuration
/// Database component.
/// </summary>
public interface IKpiHistoryRepository
{
/// <summary>
/// Bulk-inserts a batch of captured samples (one sampling pass across all
/// registered sources).
/// </summary>
/// <param name="samples">The samples to record.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task that represents the asynchronous write.</returns>
Task RecordSamplesAsync(IReadOnlyCollection<KpiSample> samples, CancellationToken cancellationToken = default);
/// <summary>
/// Returns the raw points for one series in <c>[fromUtc, toUtc]</c>, ordered by
/// <see cref="KpiSample.CapturedAtUtc"/> ascending.
/// </summary>
/// <param name="source">Source identifier — a value from <see cref="KpiSources"/>.</param>
/// <param name="metric">Metric name from the source's catalog.</param>
/// <param name="scope">Scope discriminator — a value from <see cref="KpiScopes"/>.</param>
/// <param name="scopeKey">Scope qualifier (site id / node name); <c>null</c> for the Global scope.</param>
/// <param name="fromUtc">Inclusive lower bound of the time window (UTC).</param>
/// <param name="toUtc">Inclusive upper bound of the time window (UTC).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>
/// A task resolving to the raw series points in ascending capture order; empty when
/// the series has no samples in the window.
/// </returns>
Task<IReadOnlyList<KpiSeriesPoint>> GetRawSeriesAsync(
string source, string metric, string scope, string? scopeKey,
DateTime fromUtc, DateTime toUtc, CancellationToken cancellationToken = default);
/// <summary>
/// Bulk-deletes rows whose <see cref="KpiSample.CapturedAtUtc"/> is strictly older
/// than <paramref name="before"/> (retention purge).
/// </summary>
/// <param name="before">UTC cut-off; rows captured before this instant are deleted.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task resolving to the number of rows deleted.</returns>
Task<int> PurgeOlderThanAsync(DateTime before, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,21 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi;
/// <summary>
/// Canonical KPI scope discriminators (M6 "KPI History &amp; Trends") — the value of
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Entities.Kpi.KpiSample.Scope"/>. Each
/// constant's value equals its name. The companion
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Entities.Kpi.KpiSample.ScopeKey"/> qualifies
/// the scope: <c>null</c> for <see cref="Global"/>, the site id for <see cref="Site"/>,
/// the node name for <see cref="Node"/>.
/// </summary>
public static class KpiScopes
{
/// <summary>System-wide sample; <c>ScopeKey</c> is <c>null</c>.</summary>
public const string Global = "Global";
/// <summary>Per-site sample; <c>ScopeKey</c> is the site id.</summary>
public const string Site = "Site";
/// <summary>Per-node sample; <c>ScopeKey</c> is the node name.</summary>
public const string Node = "Node";
}
@@ -0,0 +1,11 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi;
/// <summary>
/// A single bucketed point of a KPI series (M6 "KPI History &amp; Trends") — the
/// aggregated value of one series over the bucket starting at
/// <see cref="BucketStartUtc"/>. Returned by the bucketed query path and the
/// reusable trend chart.
/// </summary>
/// <param name="BucketStartUtc">UTC start of the time bucket this point covers.</param>
/// <param name="Value">Aggregated value for the bucket — counts exact; ages as seconds.</param>
public readonly record struct KpiSeriesPoint(DateTime BucketStartUtc, double Value);
@@ -0,0 +1,22 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi;
/// <summary>
/// Canonical KPI source identifiers (M6 "KPI History &amp; Trends") — the value of
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Entities.Kpi.KpiSample.Source"/> and the
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Kpi.IKpiSampleSource.Source"/>
/// each owning component reports. Each constant's value equals its name.
/// </summary>
public static class KpiSources
{
/// <summary>Notification Outbox (#21) delivery KPIs.</summary>
public const string NotificationOutbox = "NotificationOutbox";
/// <summary>Site Call Audit (#22) cached-call KPIs.</summary>
public const string SiteCallAudit = "SiteCallAudit";
/// <summary>Audit Log (#23) ingest / backlog KPIs.</summary>
public const string AuditLog = "AuditLog";
/// <summary>Site Health (#11) monitoring KPIs.</summary>
public const string SiteHealth = "SiteHealth";
}
@@ -0,0 +1,77 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Kpi;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi;
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Kpi;
public class KpiSampleTests
{
[Fact]
public void KpiSample_ConstructedWithRequiredMembers_RoundTripsValues()
{
var capturedAt = new DateTime(2026, 6, 15, 12, 30, 0, DateTimeKind.Utc);
var sample = new KpiSample
{
Id = 42,
Source = KpiSources.NotificationOutbox,
Metric = "QueueDepth",
Scope = KpiScopes.Global,
ScopeKey = null,
Value = 17,
CapturedAtUtc = capturedAt,
};
Assert.Equal(42, sample.Id);
Assert.Equal("NotificationOutbox", sample.Source);
Assert.Equal("QueueDepth", sample.Metric);
Assert.Equal("Global", sample.Scope);
Assert.Null(sample.ScopeKey);
Assert.Equal(17, sample.Value);
Assert.Equal(capturedAt, sample.CapturedAtUtc);
Assert.Equal(DateTimeKind.Utc, sample.CapturedAtUtc.Kind);
}
[Fact]
public void KpiSample_ScopedSample_CarriesScopeKey()
{
var sample = new KpiSample
{
Source = KpiSources.SiteHealth,
Metric = "OldestPendingAgeSeconds",
Scope = KpiScopes.Site,
ScopeKey = "site-a",
Value = 12.5,
CapturedAtUtc = DateTime.UtcNow,
};
Assert.Equal(KpiScopes.Site, sample.Scope);
Assert.Equal("site-a", sample.ScopeKey);
Assert.Equal(12.5, sample.Value);
}
[Fact]
public void KpiScopesAndSources_ConstantValues_EqualTheirNames()
{
Assert.Equal("NotificationOutbox", KpiSources.NotificationOutbox);
Assert.Equal("SiteCallAudit", KpiSources.SiteCallAudit);
Assert.Equal("AuditLog", KpiSources.AuditLog);
Assert.Equal("SiteHealth", KpiSources.SiteHealth);
Assert.Equal("Global", KpiScopes.Global);
Assert.Equal("Site", KpiScopes.Site);
Assert.Equal("Node", KpiScopes.Node);
}
[Fact]
public void KpiSeriesPoint_IsValueRecordStruct_WithComponentEquality()
{
var bucket = new DateTime(2026, 6, 15, 0, 0, 0, DateTimeKind.Utc);
var a = new KpiSeriesPoint(bucket, 3.0);
var b = new KpiSeriesPoint(bucket, 3.0);
Assert.Equal(bucket, a.BucketStartUtc);
Assert.Equal(3.0, a.Value);
Assert.Equal(a, b);
}
}