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:
+117
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user