Files
ScadaBridge/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/KpiHistoryQueryService.cs
T

131 lines
7.0 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi;
using ZB.MOM.WW.ScadaBridge.KpiHistory;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
/// <summary>
/// Default <see cref="IKpiHistoryQueryService"/> implementation — fetches
/// the raw series via <see cref="IKpiHistoryRepository.GetRawSeriesAsync"/> and
/// reduces it with <see cref="KpiSeriesBucketer.Bucket"/> to at most the requested
/// (or configured-default) number of points.
/// </summary>
/// <remarks>
/// <para>
/// Mirrors <see cref="AuditLogQueryService"/>: the production constructor takes an
/// <see cref="IServiceScopeFactory"/> and opens a fresh DI scope per query —
/// resolving a fresh <see cref="IKpiHistoryRepository"/> (and therefore a fresh
/// <c>ScadaBridgeDbContext</c>) — so a trend chart's auto-load never contends with
/// the circuit-scoped context the rest of the page uses.
/// </para>
/// <para>
/// A second constructor injects an <see cref="IKpiHistoryRepository"/> directly — a
/// test seam (same dual-ctor pattern as <see cref="AuditLogQueryService"/>) so unit
/// tests can substitute a stub without standing up a DI container. Both ctors take
/// <see cref="IOptions{KpiHistoryOptions}"/> for the default series-point ceiling.
/// </para>
/// </remarks>
public sealed class KpiHistoryQueryService : IKpiHistoryQueryService
{
// Production path: open a fresh scope per operation. Null in the test-seam ctor.
private readonly IServiceScopeFactory? _scopeFactory;
// Test seam: a directly-injected repository whose lifetime the test owns.
// Null in the production ctor.
private readonly IKpiHistoryRepository? _injectedRepository;
private readonly KpiHistoryOptions _options;
/// <summary>
/// Production constructor — resolves <see cref="IKpiHistoryRepository"/> from a
/// fresh DI scope on every call so each query gets its own
/// <c>ScadaBridgeDbContext</c> and never contends with the circuit-scoped context.
/// </summary>
/// <param name="scopeFactory">Factory used to open a fresh DI scope per query.</param>
/// <param name="options">KPI History options supplying the default series-point ceiling.</param>
public KpiHistoryQueryService(
IServiceScopeFactory scopeFactory,
IOptions<KpiHistoryOptions> options)
{
_scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory));
ArgumentNullException.ThrowIfNull(options);
_options = options.Value ?? throw new ArgumentNullException(nameof(options));
}
/// <summary>
/// Test-seam constructor — injects a repository instance whose lifetime the
/// caller owns. Used by unit tests that substitute a stub repository.
/// </summary>
/// <param name="repository">The KPI history repository instance to use directly.</param>
/// <param name="options">KPI History options supplying the default series-point ceiling.</param>
public KpiHistoryQueryService(
IKpiHistoryRepository repository,
IOptions<KpiHistoryOptions> options)
{
_injectedRepository = repository ?? throw new ArgumentNullException(nameof(repository));
ArgumentNullException.ThrowIfNull(options);
_options = options.Value ?? throw new ArgumentNullException(nameof(options));
}
/// <inheritdoc />
public async Task<IReadOnlyList<KpiSeriesPoint>> GetSeriesAsync(
string source, string metric, string scope, string? scopeKey,
DateTime fromUtc, DateTime toUtc, int? maxPoints = null, CancellationToken cancellationToken = default)
{
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, aggregation);
}
// 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 series = await FetchSeriesAsync(
repository, source, metric, scope, scopeKey, fromUtc, toUtc, cancellationToken);
return KpiSeriesBucketer.Bucket(series, fromUtc, toUtc, effectiveMax, aggregation);
}
/// <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.
/// <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,
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);
}
}