feat(kpi): K2 — KpiSample EF mapping + KpiHistoryRepository + AddKpiSampleTable migration

This commit is contained in:
Joseph Doherty
2026-06-17 19:44:51 -04:00
parent 460777bffa
commit cabc557629
8 changed files with 2151 additions and 0 deletions
@@ -0,0 +1,55 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Kpi;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Configurations;
/// <summary>
/// Maps the <see cref="KpiSample"/> POCO to the central <c>KpiSample</c> table
/// (M6 "KPI History &amp; Trends") — the tall / EAV row written by the recorder
/// singleton. Operational history, NOT audit, so the table is non-partitioned,
/// standard <c>[PRIMARY]</c> filegroup, no DB-role restriction.
/// </summary>
/// <remarks>
/// Two named indexes back the access paths: <c>IX_KpiSample_Series</c> covers the
/// bucketed series query (filter by Source/Metric/Scope/ScopeKey, ordered by
/// capture time) and <c>IX_KpiSample_Captured</c> backs the retention purge
/// (delete by <see cref="KpiSample.CapturedAtUtc"/>).
/// </remarks>
public class KpiSampleEntityTypeConfiguration : IEntityTypeConfiguration<KpiSample>
{
/// <summary>
/// Configures the EF Core entity type mapping for <see cref="KpiSample"/>.
/// </summary>
/// <param name="builder">The entity type builder to configure.</param>
public void Configure(EntityTypeBuilder<KpiSample> builder)
{
builder.ToTable("KpiSample");
// Surrogate long identity assigned by the store.
builder.HasKey(s => s.Id);
// Catalog-bounded ASCII columns — values come from the KpiSources /
// KpiScopes catalogs and per-source metric names.
builder.Property(s => s.Source).HasMaxLength(64).IsUnicode(false).IsRequired();
builder.Property(s => s.Metric).HasMaxLength(64).IsUnicode(false).IsRequired();
builder.Property(s => s.Scope).HasMaxLength(16).IsUnicode(false).IsRequired();
// Scope qualifier — null for the Global scope.
builder.Property(s => s.ScopeKey).HasMaxLength(64).IsUnicode(false);
// Measured value — double / float column.
builder.Property(s => s.Value);
builder.Property(s => s.CapturedAtUtc).IsRequired();
// Series index — backs the bucketed query path (filter one series, scan in
// capture order). Names locked for migration discoverability.
builder.HasIndex(s => new { s.Source, s.Metric, s.Scope, s.ScopeKey, s.CapturedAtUtc })
.HasDatabaseName("IX_KpiSample_Series");
// Captured index — backs the retention purge (delete by capture time).
builder.HasIndex(s => s.CapturedAtUtc)
.HasDatabaseName("IX_KpiSample_Captured");
}
}
@@ -0,0 +1,50 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
{
/// <inheritdoc />
public partial class AddKpiSampleTable : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "KpiSample",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
Source = table.Column<string>(type: "varchar(64)", unicode: false, maxLength: 64, nullable: false),
Metric = table.Column<string>(type: "varchar(64)", unicode: false, maxLength: 64, nullable: false),
Scope = table.Column<string>(type: "varchar(16)", unicode: false, maxLength: 16, nullable: false),
ScopeKey = table.Column<string>(type: "varchar(64)", unicode: false, maxLength: 64, nullable: true),
Value = table.Column<double>(type: "float", nullable: false),
CapturedAtUtc = table.Column<DateTime>(type: "datetime2", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_KpiSample", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_KpiSample_Captured",
table: "KpiSample",
column: "CapturedAtUtc");
migrationBuilder.CreateIndex(
name: "IX_KpiSample_Series",
table: "KpiSample",
columns: new[] { "Source", "Metric", "Scope", "ScopeKey", "CapturedAtUtc" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "KpiSample");
}
}
}
@@ -650,6 +650,54 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
b.ToTable("InstanceNativeAlarmSourceOverrides");
});
modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Kpi.KpiSample", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<DateTime>("CapturedAtUtc")
.HasColumnType("datetime2");
b.Property<string>("Metric")
.IsRequired()
.HasMaxLength(64)
.IsUnicode(false)
.HasColumnType("varchar(64)");
b.Property<string>("Scope")
.IsRequired()
.HasMaxLength(16)
.IsUnicode(false)
.HasColumnType("varchar(16)");
b.Property<string>("ScopeKey")
.HasMaxLength(64)
.IsUnicode(false)
.HasColumnType("varchar(64)");
b.Property<string>("Source")
.IsRequired()
.HasMaxLength(64)
.IsUnicode(false)
.HasColumnType("varchar(64)");
b.Property<double>("Value")
.HasColumnType("float");
b.HasKey("Id");
b.HasIndex("CapturedAtUtc")
.HasDatabaseName("IX_KpiSample_Captured");
b.HasIndex("Source", "Metric", "Scope", "ScopeKey", "CapturedAtUtc")
.HasDatabaseName("IX_KpiSample_Series");
b.ToTable("KpiSample", (string)null);
});
modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.Notification", b =>
{
b.Property<string>("NotificationId")
@@ -0,0 +1,66 @@
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Kpi;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
/// <summary>
/// EF Core implementation of <see cref="IKpiHistoryRepository"/> over the central
/// <c>KpiSample</c> table (M6 "KPI History &amp; Trends"). See the interface for the
/// contract; this class adds notes on the data-access strategy per method.
/// </summary>
public class KpiHistoryRepository : IKpiHistoryRepository
{
private readonly ScadaBridgeDbContext _context;
/// <summary>
/// Initializes a new instance of the <see cref="KpiHistoryRepository"/> class.
/// </summary>
/// <param name="context">The EF Core database context.</param>
public KpiHistoryRepository(ScadaBridgeDbContext context)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
}
/// <inheritdoc />
public async Task RecordSamplesAsync(
IReadOnlyCollection<KpiSample> samples, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(samples);
// Bulk-insert one sampling pass. AddRange + a single SaveChanges keeps the
// whole batch in one round-trip; the store assigns each row's identity.
_context.KpiSamples.AddRange(samples);
await _context.SaveChangesAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<KpiSeriesPoint>> GetRawSeriesAsync(
string source, string metric, string scope, string? scopeKey,
DateTime fromUtc, DateTime toUtc, CancellationToken cancellationToken = default)
{
// The ScopeKey == scopeKey comparison is intentional: when scopeKey is null
// EF translates it to "ScopeKey IS NULL", which matches the Global-scope rows
// (null key) and excludes the site/node-scoped rows that carry a non-null key.
return await _context.KpiSamples
.Where(s => s.Source == source
&& s.Metric == metric
&& s.Scope == scope
&& s.ScopeKey == scopeKey
&& s.CapturedAtUtc >= fromUtc
&& s.CapturedAtUtc <= toUtc)
.OrderBy(s => s.CapturedAtUtc)
.Select(s => new KpiSeriesPoint(s.CapturedAtUtc, s.Value))
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<int> PurgeOlderThanAsync(DateTime before, CancellationToken cancellationToken = default)
{
// Set-based delete — no entity materialisation; returns the rows affected.
return await _context.KpiSamples
.Where(s => s.CapturedAtUtc < before)
.ExecuteDeleteAsync(cancellationToken);
}
}
@@ -8,6 +8,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Kpi;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Security;
@@ -130,6 +131,10 @@ public class ScadaBridgeDbContext : DbContext, IDataProtectionKeyContext
/// <summary>Gets the set of site calls.</summary>
public DbSet<SiteCall> SiteCalls => Set<SiteCall>();
// KPI History (M6 "KPI History & Trends")
/// <summary>Gets the set of KPI samples (central tall/EAV KPI-history backbone).</summary>
public DbSet<KpiSample> KpiSamples => Set<KpiSample>();
// Data Protection Keys (for shared ASP.NET Data Protection across nodes)
/// <summary>Gets the set of data protection keys.</summary>
public DbSet<DataProtectionKey> DataProtectionKeys => Set<DataProtectionKey>();
@@ -54,6 +54,7 @@ public static class ServiceCollectionExtensions
services.AddScoped<INotificationOutboxRepository, NotificationOutboxRepository>();
services.AddScoped<IAuditLogRepository, AuditLogRepository>();
services.AddScoped<ISiteCallAuditRepository, SiteCallAuditRepository>();
services.AddScoped<IKpiHistoryRepository, KpiHistoryRepository>();
// Auth re-arch (C5): inbound API keys are no longer persisted in SQL Server —
// the repository now exposes only API-method access, so a plain scoped
// registration suffices (no peppered-hasher accessor to wire).
@@ -0,0 +1,139 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Kpi;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Repositories;
/// <summary>
/// M6 (K2) coverage for <see cref="KpiHistoryRepository"/> over the in-memory SQLite
/// harness. Exercises the bulk write, the single-series query (including the
/// null-ScopeKey Global match), and the retention purge.
/// </summary>
public class KpiHistoryRepositoryTests
{
// Fixed UTC base so the time assertions are deterministic.
private static readonly DateTime Base = new(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc);
private static ScadaBridgeDbContext NewContext() => SqliteTestHelper.CreateInMemoryContext();
private static KpiSample Sample(
string source, string metric, string scope, string? scopeKey, double value, DateTime capturedAtUtc) =>
new()
{
Source = source,
Metric = metric,
Scope = scope,
ScopeKey = scopeKey,
Value = value,
CapturedAtUtc = capturedAtUtc,
};
[Fact]
public async Task GetRawSeriesAsync_ReturnsOnlyMatchingSeries_InAscendingTimeOrder()
{
await using var ctx = NewContext();
var repo = new KpiHistoryRepository(ctx);
// Two metrics ("queueDepth", "parkedCount") under one source; the target
// series ("queueDepth", Global, null key) has two points at different
// timestamps — inserted out of order to prove the OrderBy.
await repo.RecordSamplesAsync(new[]
{
// Target series — Global / null ScopeKey, two points (later first).
Sample("NotificationOutbox", "queueDepth", "Global", null, value: 7, capturedAtUtc: Base.AddMinutes(10)),
Sample("NotificationOutbox", "queueDepth", "Global", null, value: 3, capturedAtUtc: Base.AddMinutes(5)),
// Different metric — must be excluded.
Sample("NotificationOutbox", "parkedCount", "Global", null, value: 99, capturedAtUtc: Base.AddMinutes(5)),
// Same metric but a Site scope with a non-null key — must be excluded
// from the Global (null-key) query.
Sample("NotificationOutbox", "queueDepth", "Site", "plant-a", value: 42, capturedAtUtc: Base.AddMinutes(5)),
});
var series = await repo.GetRawSeriesAsync(
"NotificationOutbox", "queueDepth", "Global", scopeKey: null,
fromUtc: Base, toUtc: Base.AddMinutes(60));
// Only the two Global/null-key queueDepth points, ascending by capture time.
Assert.Equal(2, series.Count);
Assert.Equal(Base.AddMinutes(5), series[0].BucketStartUtc);
Assert.Equal(3, series[0].Value);
Assert.Equal(Base.AddMinutes(10), series[1].BucketStartUtc);
Assert.Equal(7, series[1].Value);
}
[Fact]
public async Task GetRawSeriesAsync_SiteScopedKey_MatchesOnlyThatKey()
{
await using var ctx = NewContext();
var repo = new KpiHistoryRepository(ctx);
await repo.RecordSamplesAsync(new[]
{
Sample("SiteCallAudit", "buffered", "Site", "plant-a", value: 5, capturedAtUtc: Base.AddMinutes(5)),
Sample("SiteCallAudit", "buffered", "Site", "plant-b", value: 8, capturedAtUtc: Base.AddMinutes(5)),
// A Global (null-key) row with the same metric must NOT leak into a
// site-keyed query.
Sample("SiteCallAudit", "buffered", "Global", null, value: 13, capturedAtUtc: Base.AddMinutes(5)),
});
var series = await repo.GetRawSeriesAsync(
"SiteCallAudit", "buffered", "Site", scopeKey: "plant-a",
fromUtc: Base, toUtc: Base.AddMinutes(60));
Assert.Single(series);
Assert.Equal(5, series[0].Value);
}
[Fact]
public async Task GetRawSeriesAsync_HonorsTimeWindowBounds()
{
await using var ctx = NewContext();
var repo = new KpiHistoryRepository(ctx);
await repo.RecordSamplesAsync(new[]
{
Sample("Health", "ageSeconds", "Global", null, value: 1, capturedAtUtc: Base),
Sample("Health", "ageSeconds", "Global", null, value: 2, capturedAtUtc: Base.AddMinutes(30)),
// Outside the [from, to] window — excluded.
Sample("Health", "ageSeconds", "Global", null, value: 3, capturedAtUtc: Base.AddMinutes(120)),
});
var series = await repo.GetRawSeriesAsync(
"Health", "ageSeconds", "Global", scopeKey: null,
fromUtc: Base, toUtc: Base.AddMinutes(60));
Assert.Equal(2, series.Count);
Assert.Equal(new[] { 1d, 2d }, series.Select(p => p.Value).ToArray());
}
[Fact]
public async Task PurgeOlderThanAsync_DeletesOnlyRowsOlderThanCutoff_AndReturnsCount()
{
await using var ctx = NewContext();
var repo = new KpiHistoryRepository(ctx);
await repo.RecordSamplesAsync(new[]
{
// Two rows strictly older than the cutoff — should be purged.
Sample("NotificationOutbox", "queueDepth", "Global", null, value: 1, capturedAtUtc: Base.AddDays(-10)),
Sample("NotificationOutbox", "queueDepth", "Global", null, value: 2, capturedAtUtc: Base.AddDays(-8)),
// One row AT the cutoff — strictly-older predicate keeps it.
Sample("NotificationOutbox", "queueDepth", "Global", null, value: 3, capturedAtUtc: Base.AddDays(-7)),
// One row newer than the cutoff — kept.
Sample("NotificationOutbox", "queueDepth", "Global", null, value: 4, capturedAtUtc: Base.AddDays(-1)),
});
var cutoff = Base.AddDays(-7);
var deleted = await repo.PurgeOlderThanAsync(cutoff);
Assert.Equal(2, deleted);
var remaining = await repo.GetRawSeriesAsync(
"NotificationOutbox", "queueDepth", "Global", scopeKey: null,
fromUtc: Base.AddDays(-30), toUtc: Base.AddDays(30));
// The cutoff row (==) and the newer row survive.
Assert.Equal(2, remaining.Count);
Assert.Equal(new[] { 3d, 4d }, remaining.Select(p => p.Value).ToArray());
}
}