fix(sitecallaudit): time-sliced terminal purge replaces the single year-scale DELETE (plan R2-04 T11)
This commit is contained in:
+23
-3
@@ -246,9 +246,29 @@ OPTION (RECOMPILE);";
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<int> PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default)
|
public async Task<int> PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
return await _context.Database.ExecuteSqlInterpolatedAsync(
|
// Time-sliced batches (arch-review 04 round 2, R6 — the one maintenance DELETE
|
||||||
$"DELETE FROM dbo.SiteCalls WHERE TerminalAtUtc IS NOT NULL AND TerminalAtUtc < {olderThanUtc};",
|
// that missed round 1's batching pass): each DELETE covers at most one DAY of
|
||||||
ct);
|
// terminal rows, capping the lock/log footprint per statement. Steady state
|
||||||
|
// (daily purge, 365-day retention) is a single slice; only catch-up after an
|
||||||
|
// outage runs several. One-day (not one-hour) slices are proportionate to
|
||||||
|
// SiteCalls volume, which is far below KpiSample's. The MIN() anchor and the
|
||||||
|
// DELETE predicate both seek IX_SiteCalls_Terminal (filtered IS NOT NULL).
|
||||||
|
var total = 0;
|
||||||
|
var floor = await _context.SiteCalls
|
||||||
|
.Where(s => s.TerminalAtUtc != null && s.TerminalAtUtc < olderThanUtc)
|
||||||
|
.MinAsync(s => s.TerminalAtUtc, ct);
|
||||||
|
while (floor is not null && floor < olderThanUtc)
|
||||||
|
{
|
||||||
|
var ceiling = floor.Value.AddDays(1) < olderThanUtc ? floor.Value.AddDays(1) : olderThanUtc;
|
||||||
|
total += await _context.Database.ExecuteSqlInterpolatedAsync(
|
||||||
|
$"DELETE FROM dbo.SiteCalls WHERE TerminalAtUtc IS NOT NULL AND TerminalAtUtc < {ceiling};",
|
||||||
|
ct);
|
||||||
|
floor = await _context.SiteCalls
|
||||||
|
.Where(s => s.TerminalAtUtc != null && s.TerminalAtUtc < olderThanUtc)
|
||||||
|
.MinAsync(s => s.TerminalAtUtc, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
return total;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Terminal status string literals for the interval-throughput KPIs. The
|
// Terminal status string literals for the interval-throughput KPIs. The
|
||||||
|
|||||||
+101
@@ -1,4 +1,6 @@
|
|||||||
|
using System.Data.Common;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||||
@@ -474,6 +476,69 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
|
|||||||
Assert.NotNull(await repo.GetAsync(recentTerminalId));
|
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 -------------------------------------------------
|
// --- KPI snapshot tests -------------------------------------------------
|
||||||
|
|
||||||
[SkippableFact]
|
[SkippableFact]
|
||||||
@@ -824,4 +889,40 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
|
|||||||
Assert.Equal("Attempted", loaded.Status);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user