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:
@@ -79,17 +79,39 @@ public sealed class KpiHistoryQueryService : IKpiHistoryQueryService
|
||||
// Test-seam ctor: use the injected repository directly.
|
||||
if (_injectedRepository is not null)
|
||||
{
|
||||
var injectedRaw = await _injectedRepository.GetRawSeriesAsync(
|
||||
source, metric, scope, scopeKey, fromUtc, toUtc, cancellationToken);
|
||||
return KpiSeriesBucketer.Bucket(injectedRaw, fromUtc, toUtc, effectiveMax);
|
||||
var injectedSeries = await FetchSeriesAsync(
|
||||
_injectedRepository, source, metric, scope, scopeKey, fromUtc, toUtc, cancellationToken);
|
||||
return KpiSeriesBucketer.Bucket(injectedSeries, fromUtc, toUtc, effectiveMax);
|
||||
}
|
||||
|
||||
// Production: a fresh scope (and thus a fresh DbContext) per query so a
|
||||
// chart's auto-load never shares the circuit-scoped context.
|
||||
await using var serviceScope = _scopeFactory!.CreateAsyncScope();
|
||||
var repository = serviceScope.ServiceProvider.GetRequiredService<IKpiHistoryRepository>();
|
||||
var raw = await repository.GetRawSeriesAsync(
|
||||
source, metric, scope, scopeKey, fromUtc, toUtc, cancellationToken);
|
||||
return KpiSeriesBucketer.Bucket(raw, fromUtc, toUtc, effectiveMax);
|
||||
var series = await FetchSeriesAsync(
|
||||
repository, source, metric, scope, scopeKey, fromUtc, toUtc, cancellationToken);
|
||||
return KpiSeriesBucketer.Bucket(series, fromUtc, toUtc, effectiveMax);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Routes a series fetch to the raw-sample or hourly-rollup repository method by
|
||||
/// the requested window width. Windows wider than
|
||||
/// <see cref="KpiHistoryOptions.RollupThresholdHours"/> read the pre-aggregated
|
||||
/// hourly rollups (≈60× fewer rows scanned on 30 d/90 d ranges); windows at or below
|
||||
/// the threshold read raw samples, preserving intra-minute detail on the 24 h/7 d
|
||||
/// charts. Both paths return the same ascending <see cref="KpiSeriesPoint"/> shape, so
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private Task<IReadOnlyList<KpiSeriesPoint>> FetchSeriesAsync(
|
||||
IKpiHistoryRepository repository,
|
||||
string source, string metric, string scope, string? scopeKey,
|
||||
DateTime fromUtc, DateTime toUtc, CancellationToken cancellationToken)
|
||||
{
|
||||
var windowHours = (toUtc - fromUtc).TotalHours;
|
||||
return windowHours > _options.RollupThresholdHours
|
||||
? repository.GetHourlySeriesAsync(source, metric, scope, scopeKey, fromUtc, toUtc, cancellationToken)
|
||||
: repository.GetRawSeriesAsync(source, metric, scope, scopeKey, fromUtc, toUtc, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user