perf(central): set-based ingest, aligned partition purge, KPI query shapes, EF hygiene
This commit is contained in:
+87
@@ -325,6 +325,57 @@ public class KpiHistoryRepositoryTests
|
||||
Assert.Equal(2, row.SampleCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FoldHourlyRollupsAsync_PreloadsExistingRollups_WithoutAPerSeriesHourProbe()
|
||||
{
|
||||
// WP2.2: the fold used to issue one FirstOrDefaultAsync existence probe per
|
||||
// (series, hour) group before writing anything — an N+1 that scaled with
|
||||
// the metric catalogue times the lookback window. It now preloads the whole
|
||||
// window in a single query and resolves each group from a dictionary.
|
||||
//
|
||||
// Six distinct series across two hours = twelve groups, so the old shape
|
||||
// issued twelve SELECTs against KpiRollupHourly. The new shape issues one
|
||||
// (plus the KpiSample read). This asserts the count stays small and
|
||||
// constant rather than tracking the group count.
|
||||
var counter = new RollupSelectCountingInterceptor();
|
||||
await using var ctx = SqliteTestHelper.CreateInMemoryContext(counter);
|
||||
var repo = new KpiHistoryRepository(ctx);
|
||||
|
||||
var samples = new List<KpiSample>();
|
||||
for (var series = 0; series < 6; series++)
|
||||
{
|
||||
foreach (var hourOffset in new[] { 0, 1 })
|
||||
{
|
||||
samples.Add(Sample(
|
||||
"NotificationOutbox",
|
||||
"metric" + series,
|
||||
"Global",
|
||||
null,
|
||||
value: series + hourOffset,
|
||||
capturedAtUtc: Base.AddHours(hourOffset).AddMinutes(10)));
|
||||
}
|
||||
}
|
||||
|
||||
await repo.RecordSamplesAsync(samples);
|
||||
|
||||
// Seed the rollups so the SECOND fold takes the re-fold (update) path —
|
||||
// the branch that most needed the per-group probe.
|
||||
await repo.FoldHourlyRollupsAsync(Base, Base.AddHours(2));
|
||||
|
||||
counter.Reset();
|
||||
await repo.FoldHourlyRollupsAsync(Base, Base.AddHours(2));
|
||||
|
||||
Assert.True(
|
||||
counter.RollupSelectCount <= 1,
|
||||
$"expected the fold to preload existing rollups in a single query; it issued {counter.RollupSelectCount} SELECTs against KpiRollupHourly");
|
||||
|
||||
// And the fold is still correct: twelve series-hours, values unchanged by
|
||||
// the re-fold.
|
||||
var rollups = await ctx.KpiRollupHourly.AsNoTracking().ToListAsync();
|
||||
Assert.Equal(12, rollups.Count);
|
||||
Assert.All(rollups, r => Assert.Equal(1, r.SampleCount));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetHourlySeriesAsync_ReturnsAscending_AndHonorsNullVsSiteScopeKey()
|
||||
{
|
||||
@@ -540,6 +591,42 @@ public class KpiHistoryRepositoryTests
|
||||
/// async non-query entry points (<c>ExecuteDeleteAsync</c> routes through the
|
||||
/// async path).
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// Counts reader commands that SELECT from <c>KpiRollupHourly</c>, so a test
|
||||
/// can prove the hourly fold resolves existing rows from a single preload
|
||||
/// rather than one probe per (series, hour) group.
|
||||
/// </summary>
|
||||
private sealed class RollupSelectCountingInterceptor : DbCommandInterceptor
|
||||
{
|
||||
public int RollupSelectCount { get; private set; }
|
||||
|
||||
public void Reset() => RollupSelectCount = 0;
|
||||
|
||||
private void CountIfRollupSelect(DbCommand command)
|
||||
{
|
||||
if (command.CommandText.Contains("KpiRollupHourly", StringComparison.OrdinalIgnoreCase)
|
||||
&& command.CommandText.Contains("SELECT", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
RollupSelectCount++;
|
||||
}
|
||||
}
|
||||
|
||||
public override InterceptionResult<DbDataReader> ReaderExecuting(
|
||||
DbCommand command, CommandEventData eventData, InterceptionResult<DbDataReader> result)
|
||||
{
|
||||
CountIfRollupSelect(command);
|
||||
return base.ReaderExecuting(command, eventData, result);
|
||||
}
|
||||
|
||||
public override ValueTask<InterceptionResult<DbDataReader>> ReaderExecutingAsync(
|
||||
DbCommand command, CommandEventData eventData, InterceptionResult<DbDataReader> result,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
CountIfRollupSelect(command);
|
||||
return base.ReaderExecutingAsync(command, eventData, result, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class DeleteCountingInterceptor : DbCommandInterceptor
|
||||
{
|
||||
public int DeleteCount { get; private set; }
|
||||
|
||||
Reference in New Issue
Block a user