perf(kpi-history): time-sliced batched purge replaces the single daily mega-DELETE
This commit is contained in:
+77
@@ -1,3 +1,5 @@
|
||||
using System.Data.Common;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Kpi;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
|
||||
@@ -154,4 +156,79 @@ public class KpiHistoryRepositoryTests
|
||||
Assert.Equal(2, remaining.Count);
|
||||
Assert.Equal(new[] { 3d, 4d }, remaining.Select(p => p.Value).ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PurgeOlderThanAsync_MultiWindow_IssuesMoreThanOneDelete_AndReturnsTotal()
|
||||
{
|
||||
// Rows span several days, each in its own hour-bucket, all older than the
|
||||
// cutoff. The batched purge must slice them into per-hour DELETE windows
|
||||
// (arch-review 04, P4) rather than one giant DELETE — so more than one
|
||||
// DELETE statement is issued, and the return value still equals the total.
|
||||
var counter = new DeleteCountingInterceptor();
|
||||
await using var ctx = SqliteTestHelper.CreateInMemoryContext(counter);
|
||||
var repo = new KpiHistoryRepository(ctx);
|
||||
|
||||
await repo.RecordSamplesAsync(new[]
|
||||
{
|
||||
// Three distinct hour-buckets strictly older than the cutoff — purged.
|
||||
Sample("NotificationOutbox", "queueDepth", "Global", null, value: 1, capturedAtUtc: Base.AddDays(-5)),
|
||||
Sample("NotificationOutbox", "queueDepth", "Global", null, value: 2, capturedAtUtc: Base.AddDays(-4)),
|
||||
Sample("NotificationOutbox", "queueDepth", "Global", null, value: 3, capturedAtUtc: Base.AddDays(-3)),
|
||||
// Newer than the cutoff — kept, and never scanned into a delete window.
|
||||
Sample("NotificationOutbox", "queueDepth", "Global", null, value: 4, capturedAtUtc: Base.AddDays(-1)),
|
||||
Sample("NotificationOutbox", "queueDepth", "Global", null, value: 5, capturedAtUtc: Base),
|
||||
});
|
||||
|
||||
// Only count DELETEs the purge itself issues (RecordSamples above emits none).
|
||||
counter.Reset();
|
||||
|
||||
var deleted = await repo.PurgeOlderThanAsync(Base.AddDays(-2));
|
||||
|
||||
// All three old rows gone; the batched total is the sum across windows.
|
||||
Assert.Equal(3, deleted);
|
||||
Assert.True(counter.DeleteCount > 1,
|
||||
$"expected the batched purge to issue more than one DELETE, got {counter.DeleteCount}");
|
||||
|
||||
var remaining = await repo.GetRawSeriesAsync(
|
||||
"NotificationOutbox", "queueDepth", "Global", scopeKey: null,
|
||||
fromUtc: Base.AddDays(-30), toUtc: Base.AddDays(30));
|
||||
Assert.Equal(new[] { 4d, 5d }, remaining.Select(p => p.Value).ToArray());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EF command interceptor that counts the DELETE statements actually issued
|
||||
/// to the store, so a test can prove the purge is sliced into multiple
|
||||
/// windowed DELETEs rather than one mega-statement. Threads both the sync and
|
||||
/// async non-query entry points (<c>ExecuteDeleteAsync</c> routes through the
|
||||
/// async path).
|
||||
/// </summary>
|
||||
private sealed class DeleteCountingInterceptor : 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +87,25 @@ public static class SqliteTestHelper
|
||||
return context;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory SQLite context with EF command interceptors attached — lets a
|
||||
/// test observe the actual SQL a repository emits (e.g. count DELETE statements
|
||||
/// issued by a batched purge).
|
||||
/// </summary>
|
||||
public static ScadaBridgeDbContext CreateInMemoryContext(params IInterceptor[] interceptors)
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ScadaBridgeDbContext>()
|
||||
.UseSqlite("DataSource=:memory:")
|
||||
.ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning))
|
||||
.AddInterceptors(interceptors)
|
||||
.Options;
|
||||
|
||||
var context = new SqliteTestDbContext(options);
|
||||
context.Database.OpenConnection();
|
||||
context.Database.EnsureCreated();
|
||||
return context;
|
||||
}
|
||||
|
||||
public static ScadaBridgeDbContext CreateFileContext(string dbPath)
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ScadaBridgeDbContext>()
|
||||
|
||||
Reference in New Issue
Block a user