diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/SiteCallAuditRepository.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/SiteCallAuditRepository.cs index 075b3626..29137a77 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/SiteCallAuditRepository.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/SiteCallAuditRepository.cs @@ -246,9 +246,29 @@ OPTION (RECOMPILE);"; /// public async Task PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default) { - return await _context.Database.ExecuteSqlInterpolatedAsync( - $"DELETE FROM dbo.SiteCalls WHERE TerminalAtUtc IS NOT NULL AND TerminalAtUtc < {olderThanUtc};", - ct); + // Time-sliced batches (arch-review 04 round 2, R6 — the one maintenance DELETE + // that missed round 1's batching pass): each DELETE covers at most one DAY of + // 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 diff --git a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/SiteCallAuditRepositoryTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/SiteCallAuditRepositoryTests.cs index 47aa2799..79569052 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/SiteCallAuditRepositoryTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/SiteCallAuditRepositoryTests.cs @@ -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 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() + .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(); + 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 Assert.Equal("Attempted", loaded.Status); } } + + /// + /// 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. ExecuteSqlInterpolatedAsync routes + /// through the async non-query path. + /// + 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 NonQueryExecuting( + DbCommand command, CommandEventData eventData, InterceptionResult result) + { + CountIfDelete(command); + return base.NonQueryExecuting(command, eventData, result); + } + + public override ValueTask> NonQueryExecutingAsync( + DbCommand command, CommandEventData eventData, InterceptionResult result, + CancellationToken cancellationToken = default) + { + CountIfDelete(command); + return base.NonQueryExecutingAsync(command, eventData, result, cancellationToken); + } + } }