feat(kpi): K2 — KpiSample EF mapping + KpiHistoryRepository + AddKpiSampleTable migration
This commit is contained in:
+55
@@ -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 & 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");
|
||||
}
|
||||
}
|
||||
+1787
File diff suppressed because it is too large
Load Diff
+50
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+48
@@ -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 & 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).
|
||||
|
||||
+139
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user