fix(kpi): query service applies catalog aggregation at the bucketer — rate charts keep one unit across the raw/rollup boundary (plan R2-04 T6)

This commit is contained in:
Joseph Doherty
2026-07-13 10:10:30 -04:00
parent 54417e1ae4
commit 9a1b160e79
2 changed files with 106 additions and 2 deletions
@@ -76,12 +76,18 @@ public sealed class KpiHistoryQueryService : IKpiHistoryQueryService
{
var effectiveMax = maxPoints ?? _options.DefaultMaxSeriesPoints;
// Per-metric reduction intent: the same catalog that drives the hourly fold drives
// the chart-time bucket reduction, so a Rate series is summed per bucket on BOTH the
// raw-minute and hourly-rollup routes — one unit ("events per chart bucket") on both
// sides of the RollupThresholdHours routing boundary (arch-review 04 round 2, R3).
var aggregation = KpiMetricAggregationCatalog.Resolve(source, metric);
// Test-seam ctor: use the injected repository directly.
if (_injectedRepository is not null)
{
var injectedSeries = await FetchSeriesAsync(
_injectedRepository, source, metric, scope, scopeKey, fromUtc, toUtc, cancellationToken);
return KpiSeriesBucketer.Bucket(injectedSeries, fromUtc, toUtc, effectiveMax);
return KpiSeriesBucketer.Bucket(injectedSeries, fromUtc, toUtc, effectiveMax, aggregation);
}
// Production: a fresh scope (and thus a fresh DbContext) per query so a
@@ -90,7 +96,7 @@ public sealed class KpiHistoryQueryService : IKpiHistoryQueryService
var repository = serviceScope.ServiceProvider.GetRequiredService<IKpiHistoryRepository>();
var series = await FetchSeriesAsync(
repository, source, metric, scope, scopeKey, fromUtc, toUtc, cancellationToken);
return KpiSeriesBucketer.Bucket(series, fromUtc, toUtc, effectiveMax);
return KpiSeriesBucketer.Bucket(series, fromUtc, toUtc, effectiveMax, aggregation);
}
/// <summary>
@@ -103,6 +109,13 @@ public sealed class KpiHistoryQueryService : IKpiHistoryQueryService
/// the caller's <see cref="KpiSeriesBucketer.Bucket"/> step is agnostic to the source
/// (a 90 d rollup can still exceed the point ceiling, so bucketing still applies).
/// The boundary is strict: exactly <c>RollupThresholdHours</c> stays on the raw path.
/// <para>
/// The routing boundary no longer changes a Rate chart's magnitude: both paths reduce to
/// per-bucket sums via the catalog-driven aggregation applied in
/// <see cref="GetSeriesAsync"/>. A residual ≤~1.2× native-resolution difference exists only
/// for an unbucketed just-over-threshold rollup series (plotted at native per-hour sums) vs.
/// an at-threshold bucketed raw series — documented, not hidden (arch-review 04 round 2, R3).
/// </para>
/// </summary>
private Task<IReadOnlyList<KpiSeriesPoint>> FetchSeriesAsync(
IKpiHistoryRepository repository,