perf(central): set-based ingest, aligned partition purge, KPI query shapes, EF hygiene

This commit is contained in:
Joseph Doherty
2026-08-14 21:07:12 -04:00
parent ee193cd2bb
commit 5db2a810c0
29 changed files with 3790 additions and 266 deletions
@@ -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; }