fix(kpi-history): fold raw samples one hour at a time; only a unique-key violation is the benign fold race (review 2026-10-21 06/PERF-6, 08/P4, 08/C3)

This commit is contained in:
Joseph Doherty
2026-10-22 09:26:17 -04:00
parent e7fb71c2de
commit e69be1654f
3 changed files with 187 additions and 40 deletions
@@ -564,6 +564,123 @@ public class KpiHistoryRepositoryTests
Assert.Equal(5, series[0].Value); // the fold's own value overwrote the side-inserted -1
}
/// <summary>
/// Verifies that a multi-hour fold reads raw samples ONE HOUR AT A TIME, so a 24 h backfill
/// slice never materializes the whole slice's samples at once (review 2026-10-21 06/PERF-6,
/// 08/P4), and still folds every hour correctly.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task FoldHourlyRollupsAsync_ReadsSamplesOneHourAtATime()
{
var recorder = new SampleReadWindowInterceptor();
await using var ctx = SqliteTestHelper.CreateInMemoryContext(recorder);
var repo = new KpiHistoryRepository(ctx);
var samples = new List<KpiSample>();
for (var hour = 0; hour < 5; hour++)
{
samples.Add(Sample("NotificationOutbox", "queueDepth", "Global", null, hour, Base.AddHours(hour).AddMinutes(5)));
samples.Add(Sample("NotificationOutbox", "queueDepth", "Global", null, hour + 10, Base.AddHours(hour).AddMinutes(55)));
}
await repo.RecordSamplesAsync(samples);
await repo.FoldHourlyRollupsAsync(Base, Base.AddHours(5));
Assert.NotEmpty(recorder.Windows);
Assert.All(recorder.Windows, w => Assert.True(
w <= TimeSpan.FromHours(1), $"a KpiSample read spanned {w}; expected at most one hour"));
var series = await repo.GetHourlySeriesAsync(
"NotificationOutbox", "queueDepth", "Global", null, Base, Base.AddHours(5));
Assert.Equal(new[] { 10d, 11d, 12d, 13d, 14d }, series.Select(p => p.Value).ToArray());
}
/// <summary>
/// Verifies that a save failure that is NOT a unique-key race propagates instead of being
/// swallowed at Information as a benign failover race (review 2026-10-21 08/C3).
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Fold_NonRaceSaveFailure_Propagates()
{
await using var ctx = SqliteTestHelper.CreateInMemoryContext(new ThrowOnRollupSave());
var repo = new KpiHistoryRepository(ctx);
await repo.RecordSamplesAsync(new[]
{
Sample("NotificationOutbox", "queueDepth", "Global", null, 5, Base.AddMinutes(10)),
});
await Assert.ThrowsAsync<DbUpdateException>(() => repo.FoldHourlyRollupsAsync(Base, Base.AddHours(1)));
}
/// <summary>Fails any save that writes a rollup row with a non-constraint fault.</summary>
private sealed class ThrowOnRollupSave : Microsoft.EntityFrameworkCore.Diagnostics.SaveChangesInterceptor
{
/// <inheritdoc />
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData, InterceptionResult<int> result,
CancellationToken cancellationToken = default)
{
if (eventData.Context is not null
&& eventData.Context.ChangeTracker.Entries<KpiRollupHourly>().Any())
{
throw new DbUpdateException("simulated non-race failure (e.g. a truncation fault)");
}
return base.SavingChangesAsync(eventData, result, cancellationToken);
}
}
/// <summary>
/// Records, for every reader command against <c>KpiSample</c>, the span between its
/// smallest and largest DateTime parameter — i.e. the time window that read covers.
/// </summary>
private sealed class SampleReadWindowInterceptor : DbCommandInterceptor
{
/// <summary>The time window covered by each recorded KpiSample read.</summary>
public List<TimeSpan> Windows { get; } = new();
private void Record(DbCommand command)
{
if (!command.CommandText.Contains("FROM \"KpiSample\"", StringComparison.OrdinalIgnoreCase))
{
return;
}
var instants = command.Parameters.Cast<DbParameter>()
.Select(p => p.Value switch
{
DateTime d => (DateTime?)d,
string text when DateTime.TryParse(text, out var parsed) => parsed,
_ => null,
})
.Where(d => d is not null)
.Select(d => d!.Value)
.ToList();
if (instants.Count >= 2)
{
Windows.Add(instants.Max() - instants.Min());
}
}
/// <inheritdoc />
public override InterceptionResult<DbDataReader> ReaderExecuting(
DbCommand command, CommandEventData eventData, InterceptionResult<DbDataReader> result)
{
Record(command);
return base.ReaderExecuting(command, eventData, result);
}
/// <inheritdoc />
public override ValueTask<InterceptionResult<DbDataReader>> ReaderExecutingAsync(
DbCommand command, CommandEventData eventData, InterceptionResult<DbDataReader> result,
CancellationToken cancellationToken = default)
{
Record(command);
return base.ReaderExecutingAsync(command, eventData, result, cancellationToken);
}
}
// Local mirror of the repo's private hour-truncation, for cutoff assertions.
private static DateTime TruncateHour(DateTime utc) =>
new(utc.Year, utc.Month, utc.Day, utc.Hour, 0, 0, DateTimeKind.Utc);