feat(kpihistory): route long-range KPI queries to hourly rollups by threshold (plan #22 T5)
GetSeriesAsync now branches on window width: windows wider than RollupThresholdHours (default 168h/7d) read pre-aggregated hourly rollups via GetHourlySeriesAsync; windows at or below the threshold read raw samples via GetRawSeriesAsync (preserving intra-minute detail on 24h/7d). The boundary is strict greater-than, so exactly 168h stays on the raw path. KpiSeriesBucketer.Bucket runs unchanged on whichever series (a 90d rollup can still exceed the point ceiling). Both ctors route identically via a shared FetchSeriesAsync helper reading _options.RollupThresholdHours, the same options instance that already supplies DefaultMaxSeriesPoints. GetSeriesAsync public signature unchanged. Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
This commit is contained in:
+156
-2
@@ -20,8 +20,12 @@ public class KpiHistoryQueryServiceTests
|
||||
private static readonly DateTime From = new(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc);
|
||||
private static readonly DateTime To = new(2026, 5, 20, 11, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private static IOptions<KpiHistoryOptions> Options(int defaultMax) =>
|
||||
Microsoft.Extensions.Options.Options.Create(new KpiHistoryOptions { DefaultMaxSeriesPoints = defaultMax });
|
||||
private static IOptions<KpiHistoryOptions> Options(int defaultMax, int rollupThresholdHours = 168) =>
|
||||
Microsoft.Extensions.Options.Options.Create(new KpiHistoryOptions
|
||||
{
|
||||
DefaultMaxSeriesPoints = defaultMax,
|
||||
RollupThresholdHours = rollupThresholdHours,
|
||||
});
|
||||
|
||||
private static IReadOnlyList<KpiSeriesPoint> Series(params (int minute, double value)[] pts) =>
|
||||
pts.Select(p => new KpiSeriesPoint(From.AddMinutes(p.minute), p.value)).ToList();
|
||||
@@ -176,4 +180,154 @@ public class KpiHistoryQueryServiceTests
|
||||
Assert.Equal(2, resolvedRepos.Count);
|
||||
Assert.NotSame(resolvedRepos[0], resolvedRepos[1]);
|
||||
}
|
||||
|
||||
// ---- Task 5: raw-vs-rollup range routing by RollupThresholdHours ----------------
|
||||
|
||||
private static IKpiHistoryRepository RepoReturningEmptyForBoth()
|
||||
{
|
||||
var repo = Substitute.For<IKpiHistoryRepository>();
|
||||
repo.GetRawSeriesAsync(
|
||||
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string?>(),
|
||||
Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<KpiSeriesPoint>>(Array.Empty<KpiSeriesPoint>()));
|
||||
repo.GetHourlySeriesAsync(
|
||||
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string?>(),
|
||||
Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<KpiSeriesPoint>>(Array.Empty<KpiSeriesPoint>()));
|
||||
return repo;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSeriesAsync_24hWindow_UsesRawPath()
|
||||
{
|
||||
var repo = RepoReturningEmptyForBoth();
|
||||
var from = new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
var to = from.AddHours(24);
|
||||
|
||||
var sut = new KpiHistoryQueryService(repo, Options(defaultMax: 200));
|
||||
|
||||
await sut.GetSeriesAsync("src", "metric", "global", null, from, to);
|
||||
|
||||
await repo.Received(1).GetRawSeriesAsync(
|
||||
"src", "metric", "global", null, from, to, Arg.Any<CancellationToken>());
|
||||
await repo.DidNotReceive().GetHourlySeriesAsync(
|
||||
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string?>(),
|
||||
Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSeriesAsync_WindowExactlyAtThreshold_UsesRawPath()
|
||||
{
|
||||
// Boundary is strictly greater-than: exactly 168 h stays on the raw path.
|
||||
var repo = RepoReturningEmptyForBoth();
|
||||
var from = new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
var to = from.AddHours(168);
|
||||
|
||||
var sut = new KpiHistoryQueryService(repo, Options(defaultMax: 200, rollupThresholdHours: 168));
|
||||
|
||||
await sut.GetSeriesAsync("src", "metric", "global", null, from, to);
|
||||
|
||||
await repo.Received(1).GetRawSeriesAsync(
|
||||
"src", "metric", "global", null, from, to, Arg.Any<CancellationToken>());
|
||||
await repo.DidNotReceive().GetHourlySeriesAsync(
|
||||
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string?>(),
|
||||
Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSeriesAsync_WindowJustOverThreshold_UsesRollupPath()
|
||||
{
|
||||
// 169 h (threshold + 1) crosses the boundary → hourly rollup path.
|
||||
var repo = RepoReturningEmptyForBoth();
|
||||
var from = new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
var to = from.AddHours(169);
|
||||
|
||||
var sut = new KpiHistoryQueryService(repo, Options(defaultMax: 200, rollupThresholdHours: 168));
|
||||
|
||||
await sut.GetSeriesAsync("src", "metric", "global", null, from, to);
|
||||
|
||||
await repo.Received(1).GetHourlySeriesAsync(
|
||||
"src", "metric", "global", null, from, to, Arg.Any<CancellationToken>());
|
||||
await repo.DidNotReceive().GetRawSeriesAsync(
|
||||
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string?>(),
|
||||
Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSeriesAsync_90dWindow_UsesRollupPath()
|
||||
{
|
||||
var repo = RepoReturningEmptyForBoth();
|
||||
var from = new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
var to = from.AddDays(90);
|
||||
|
||||
var sut = new KpiHistoryQueryService(repo, Options(defaultMax: 200));
|
||||
|
||||
await sut.GetSeriesAsync("src", "metric", "global", null, from, to);
|
||||
|
||||
await repo.Received(1).GetHourlySeriesAsync(
|
||||
"src", "metric", "global", null, from, to, Arg.Any<CancellationToken>());
|
||||
await repo.DidNotReceive().GetRawSeriesAsync(
|
||||
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string?>(),
|
||||
Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSeriesAsync_RollupPath_StillBucketsWhenOverMax()
|
||||
{
|
||||
// A rollup series longer than maxPoints must still be downsampled by the
|
||||
// bucketer on top of the rollup read — the bucketer path is unchanged.
|
||||
var from = new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
var to = from.AddDays(90);
|
||||
|
||||
// 6 hourly rollup points, spread across the window in ascending order.
|
||||
var rollup = Enumerable.Range(0, 6)
|
||||
.Select(i => new KpiSeriesPoint(from.AddHours(i * 300), i))
|
||||
.ToList();
|
||||
|
||||
var repo = Substitute.For<IKpiHistoryRepository>();
|
||||
repo.GetHourlySeriesAsync(
|
||||
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string?>(),
|
||||
Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<KpiSeriesPoint>>(rollup));
|
||||
|
||||
var sut = new KpiHistoryQueryService(repo, Options(defaultMax: 3));
|
||||
|
||||
var result = await sut.GetSeriesAsync("src", "metric", "global", null, from, to);
|
||||
|
||||
// Routed to the rollup path...
|
||||
await repo.Received(1).GetHourlySeriesAsync(
|
||||
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string?>(),
|
||||
Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<CancellationToken>());
|
||||
// ...and the bucketer still applied (6 rollup rows > max of 3 → downsampled).
|
||||
Assert.NotSame(rollup, result);
|
||||
Assert.True(result.Count <= 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSeriesAsync_LongWindow_UsesRollupPath_OnProductionCtor()
|
||||
{
|
||||
// The production (IServiceScopeFactory) ctor must apply the same threshold
|
||||
// routing — a long window resolves the repo from a fresh scope and calls the
|
||||
// hourly rollup method.
|
||||
var repo = RepoReturningEmptyForBoth();
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddScoped<IKpiHistoryRepository>(_ => repo);
|
||||
|
||||
await using var provider = services.BuildServiceProvider();
|
||||
var sut = new KpiHistoryQueryService(
|
||||
provider.GetRequiredService<IServiceScopeFactory>(),
|
||||
Options(defaultMax: 200));
|
||||
|
||||
var from = new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
var to = from.AddDays(30);
|
||||
|
||||
await sut.GetSeriesAsync("src", "metric", "global", null, from, to);
|
||||
|
||||
await repo.Received(1).GetHourlySeriesAsync(
|
||||
"src", "metric", "global", null, from, to, Arg.Any<CancellationToken>());
|
||||
await repo.DidNotReceive().GetRawSeriesAsync(
|
||||
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string?>(),
|
||||
Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user