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; /// /// Default implementation — fetches /// the raw series via and /// reduces it with to at most the requested /// (or configured-default) number of points. /// /// /// /// Mirrors : the production constructor takes an /// and opens a fresh DI scope per query — /// resolving a fresh (and therefore a fresh /// ScadaBridgeDbContext) — so a trend chart's auto-load never contends with /// the circuit-scoped context the rest of the page uses. /// /// /// A second constructor injects an directly — a /// test seam (same dual-ctor pattern as ) so unit /// tests can substitute a stub without standing up a DI container. Both ctors take /// for the default series-point ceiling. /// /// 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; /// /// Production constructor — resolves from a /// fresh DI scope on every call so each query gets its own /// ScadaBridgeDbContext and never contends with the circuit-scoped context. /// /// Factory used to open a fresh DI scope per query. /// KPI History options supplying the default series-point ceiling. public KpiHistoryQueryService( IServiceScopeFactory scopeFactory, IOptions options) { _scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory)); ArgumentNullException.ThrowIfNull(options); _options = options.Value ?? throw new ArgumentNullException(nameof(options)); } /// /// Test-seam constructor — injects a repository instance whose lifetime the /// caller owns. Used by unit tests that substitute a stub repository. /// /// The KPI history repository instance to use directly. /// KPI History options supplying the default series-point ceiling. public KpiHistoryQueryService( IKpiHistoryRepository repository, IOptions options) { _injectedRepository = repository ?? throw new ArgumentNullException(nameof(repository)); ArgumentNullException.ThrowIfNull(options); _options = options.Value ?? throw new ArgumentNullException(nameof(options)); } /// public async Task> 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(); var series = await FetchSeriesAsync( repository, source, metric, scope, scopeKey, fromUtc, toUtc, cancellationToken); return KpiSeriesBucketer.Bucket(series, fromUtc, toUtc, effectiveMax, aggregation); } /// /// Routes a series fetch to the raw-sample or hourly-rollup repository method by /// the requested window width. Windows wider than /// 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 shape, so /// the caller's 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 RollupThresholdHours stays on the raw path. /// /// 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 /// . 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). /// /// private Task> 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); } }