fix(site-call-audit): KPI snapshots as single conditional-aggregate queries (review 2026-10-21 06/PERF-3, 08/P3)
This commit is contained in:
+134
@@ -0,0 +1,134 @@
|
||||
using System.Data.Common;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Query-shape coverage for the Site Call Audit KPI snapshots (review 2026-10-21
|
||||
/// 06/PERF-3 + 08/P3). Each snapshot must be a single conditional-aggregation query
|
||||
/// (plus at most one bounded oldest-pending lookup) — the old shape issued 7 (global)
|
||||
/// and 6 (per-site / per-node) sequential round trips. Correctness against the real
|
||||
/// schema is covered by the MS SQL <c>SiteCallAuditRepositoryTests</c>.
|
||||
/// </summary>
|
||||
public class SiteCallAuditRepositoryKpiQueryShapeTests
|
||||
{
|
||||
private static SiteCall NewRow(
|
||||
string site, string? node, string status, DateTime createdAt, DateTime? terminalAt = null) =>
|
||||
new()
|
||||
{
|
||||
TrackedOperationId = TrackedOperationId.New(),
|
||||
Channel = "ApiOutbound",
|
||||
Target = "ERP.GetOrder",
|
||||
SourceSite = site,
|
||||
SourceNode = node,
|
||||
Status = status,
|
||||
RetryCount = 0,
|
||||
CreatedAtUtc = createdAt,
|
||||
UpdatedAtUtc = terminalAt ?? createdAt,
|
||||
TerminalAtUtc = terminalAt,
|
||||
IngestedAtUtc = createdAt,
|
||||
};
|
||||
|
||||
/// <summary>Verifies that all three KPI snapshots issue at most two queries and return the right counts.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AllThreeKpiSnapshots_IssueAtMostTwoQueries()
|
||||
{
|
||||
var counter = new SelectCountingInterceptor();
|
||||
await using var ctx = SqliteTestHelper.CreateInMemoryContext(counter);
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
ctx.SiteCalls.Add(NewRow("plant-a", "node-a", "Attempted", now.AddMinutes(-30)));
|
||||
ctx.SiteCalls.Add(NewRow("plant-a", "node-a", "Attempted", now.AddMinutes(-1)));
|
||||
ctx.SiteCalls.Add(NewRow("plant-a", "node-a", "Parked", now.AddMinutes(-40), now.AddMinutes(-35)));
|
||||
ctx.SiteCalls.Add(NewRow("plant-a", "node-b", "Delivered", now.AddMinutes(-3), now.AddMinutes(-2)));
|
||||
ctx.SiteCalls.Add(NewRow("plant-b", null, "Failed", now.AddMinutes(-3), now.AddMinutes(-2)));
|
||||
// Historical terminal row outside the interval — contributes to no metric.
|
||||
ctx.SiteCalls.Add(NewRow("plant-c", "node-c", "Delivered", now.AddDays(-3), now.AddDays(-3)));
|
||||
await ctx.SaveChangesAsync();
|
||||
|
||||
var repo = new SiteCallAuditRepository(ctx);
|
||||
var stuckCutoff = now.AddMinutes(-10);
|
||||
var intervalSince = now.AddMinutes(-5);
|
||||
|
||||
counter.Reset();
|
||||
var global = await repo.ComputeKpisAsync(stuckCutoff, intervalSince);
|
||||
Assert.True(counter.Count <= 2,
|
||||
$"ComputeKpisAsync issued {counter.Count} queries against SiteCalls; expected <= 2");
|
||||
Assert.Equal(2, global.BufferedCount);
|
||||
Assert.Equal(1, global.ParkedCount);
|
||||
Assert.Equal(1, global.FailedLastInterval);
|
||||
Assert.Equal(1, global.DeliveredLastInterval);
|
||||
Assert.Equal(1, global.StuckCount);
|
||||
Assert.NotNull(global.OldestPendingAge);
|
||||
Assert.True(global.OldestPendingAge >= TimeSpan.FromMinutes(29));
|
||||
|
||||
counter.Reset();
|
||||
var perSite = await repo.ComputePerSiteKpisAsync(stuckCutoff, intervalSince);
|
||||
Assert.True(counter.Count <= 2,
|
||||
$"ComputePerSiteKpisAsync issued {counter.Count} queries against SiteCalls; expected <= 2");
|
||||
// plant-c contributes to no metric, so it does not surface as an all-zero row.
|
||||
Assert.Equal(new[] { "plant-a", "plant-b" }, perSite.Select(s => s.SourceSite).ToArray());
|
||||
var a = perSite[0];
|
||||
Assert.Equal((2, 1, 0, 1, 1), (a.BufferedCount, a.ParkedCount, a.FailedLastInterval, a.DeliveredLastInterval, a.StuckCount));
|
||||
Assert.NotNull(a.OldestPendingAge);
|
||||
var b = perSite[1];
|
||||
Assert.Equal((0, 0, 1, 0, 0), (b.BufferedCount, b.ParkedCount, b.FailedLastInterval, b.DeliveredLastInterval, b.StuckCount));
|
||||
Assert.Null(b.OldestPendingAge);
|
||||
|
||||
counter.Reset();
|
||||
var perNode = await repo.ComputePerNodeKpisAsync(stuckCutoff, intervalSince);
|
||||
Assert.True(counter.Count <= 2,
|
||||
$"ComputePerNodeKpisAsync issued {counter.Count} queries against SiteCalls; expected <= 2");
|
||||
// The NULL-node row and the metric-less node-c are excluded.
|
||||
Assert.Equal(new[] { "node-a", "node-b" }, perNode.Select(n => n.SourceNode).ToArray());
|
||||
var na = perNode[0];
|
||||
Assert.Equal((2, 1, 0, 0, 1), (na.BufferedCount, na.ParkedCount, na.FailedLastInterval, na.DeliveredLastInterval, na.StuckCount));
|
||||
Assert.NotNull(na.OldestPendingAge);
|
||||
var nb = perNode[1];
|
||||
Assert.Equal((0, 0, 0, 1, 0), (nb.BufferedCount, nb.ParkedCount, nb.FailedLastInterval, nb.DeliveredLastInterval, nb.StuckCount));
|
||||
Assert.Null(nb.OldestPendingAge);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records the text of every reader command touching the SiteCalls table.
|
||||
/// </summary>
|
||||
private sealed class SelectCountingInterceptor : DbCommandInterceptor
|
||||
{
|
||||
private readonly List<string> _commands = new();
|
||||
|
||||
/// <summary>The number of reader commands recorded so far.</summary>
|
||||
public int Count => _commands.Count;
|
||||
|
||||
/// <summary>Clears all recorded commands.</summary>
|
||||
public void Reset() => _commands.Clear();
|
||||
|
||||
private void Record(DbCommand command)
|
||||
{
|
||||
if (command.CommandText.Contains("SiteCalls", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_commands.Add(command.CommandText);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override InterceptionResult<DbDataReader> ReaderExecuting(
|
||||
DbCommand command, CommandEventData eventData, InterceptionResult<DbDataReader> result)
|
||||
{
|
||||
Record(command);
|
||||
return base.ReaderExecuting(command, eventData, result);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override ValueTask<InterceptionResult<DbDataReader>> ReaderExecutingAsync(
|
||||
DbCommand command, CommandEventData eventData, InterceptionResult<DbDataReader> result,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Record(command);
|
||||
return base.ReaderExecutingAsync(command, eventData, result, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user