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);
+ }
+ }
}