feat(kpi): K11 — KpiHistoryQueryService (scoped read + bucketing)
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
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 (M6 K11) — 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;
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user