fix(sitecallaudit): time-sliced terminal purge replaces the single year-scale DELETE (plan R2-04 T11)

This commit is contained in:
Joseph Doherty
2026-07-13 10:26:33 -04:00
parent 3d6973cc89
commit 8f46b6cec2
2 changed files with 124 additions and 3 deletions
@@ -1,4 +1,6 @@
using System.Data.Common;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
@@ -474,6 +476,69 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.NotNull(await repo.GetAsync(recentTerminalId));
}
[SkippableFact]
public async Task PurgeTerminal_SlicesMultiDayBacklog_IntoBoundedDeletes()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var site = NewSiteId();
var now = DateTime.UtcNow;
var cutoff = now.AddDays(-1);
// Attach a DELETE-counting interceptor so we can prove the purge issues MORE THAN ONE
// DELETE for a multi-day backlog (arch-review 04 round 2, R6 — time-sliced batching).
var deleteCounter = new DeleteCountingCommandInterceptor();
var options = new DbContextOptionsBuilder<ScadaBridgeDbContext>()
.UseSqlServer(_fixture.ConnectionString)
.AddInterceptors(deleteCounter)
.Options;
await using var context = new ScadaBridgeDbContext(options);
var repo = new SiteCallAuditRepository(context);
// Three terminal rows on three DISTINCT days beyond the cutoff — a multi-day backlog.
var oldIds = new List<TrackedOperationId>();
foreach (var days in new[] { 3, 4, 5 })
{
var id = TrackedOperationId.New();
oldIds.Add(id);
var terminalAt = now.AddDays(-days);
await repo.UpsertAsync(NewRow(
id, sourceSite: site, status: "Delivered", retryCount: 0,
createdAtUtc: terminalAt.AddMinutes(-1), updatedAtUtc: terminalAt,
terminal: true, terminalAtUtc: terminalAt));
}
// A fresh terminal row inside the keep window — must survive.
var recentTerminalId = TrackedOperationId.New();
await repo.UpsertAsync(NewRow(
recentTerminalId, sourceSite: site, status: "Delivered", retryCount: 0,
createdAtUtc: now.AddHours(-2), updatedAtUtc: now.AddHours(-1),
terminal: true, terminalAtUtc: now.AddHours(-1)));
// An OLD non-terminal row — non-terminal rows are NEVER purged on age.
var oldActiveId = TrackedOperationId.New();
await repo.UpsertAsync(NewRow(
oldActiveId, sourceSite: site, status: "Attempted", retryCount: 1,
createdAtUtc: now.AddDays(-10), updatedAtUtc: now.AddDays(-10),
terminal: false));
deleteCounter.Reset();
var purged = await repo.PurgeTerminalAsync(cutoff);
// The three seeded backlog rows are gone; the fresh terminal + old non-terminal survive.
Assert.True(purged >= 3, $"Expected at least the three backlog rows purged; got {purged}.");
foreach (var id in oldIds)
{
Assert.Null(await repo.GetAsync(id));
}
Assert.NotNull(await repo.GetAsync(recentTerminalId));
Assert.NotNull(await repo.GetAsync(oldActiveId));
// Slicing: a multi-day backlog is broken into MORE THAN ONE DELETE (one per day slice).
Assert.True(deleteCounter.DeleteCount > 1,
$"Expected the multi-day purge to slice into >1 DELETE; issued {deleteCounter.DeleteCount}.");
}
// --- KPI snapshot tests -------------------------------------------------
[SkippableFact]
@@ -824,4 +889,40 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.Equal("Attempted", loaded.Status);
}
}
/// <summary>
/// EF command interceptor that counts the DELETE statements actually issued to the store,
/// so a test can prove the terminal purge slices a multi-day backlog into several windowed
/// DELETEs rather than one year-scale statement. <c>ExecuteSqlInterpolatedAsync</c> routes
/// through the async non-query path.
/// </summary>
private sealed class DeleteCountingCommandInterceptor : DbCommandInterceptor
{
public int DeleteCount { get; private set; }
public void Reset() => DeleteCount = 0;
private void CountIfDelete(DbCommand command)
{
if (command.CommandText.Contains("DELETE", StringComparison.OrdinalIgnoreCase))
{
DeleteCount++;
}
}
public override InterceptionResult<int> NonQueryExecuting(
DbCommand command, CommandEventData eventData, InterceptionResult<int> result)
{
CountIfDelete(command);
return base.NonQueryExecuting(command, eventData, result);
}
public override ValueTask<InterceptionResult<int>> NonQueryExecutingAsync(
DbCommand command, CommandEventData eventData, InterceptionResult<int> result,
CancellationToken cancellationToken = default)
{
CountIfDelete(command);
return base.NonQueryExecutingAsync(command, eventData, result, cancellationToken);
}
}
}