using Microsoft.Extensions.DependencyInjection; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; using ZB.MOM.WW.ScadaBridge.Commons.Types; using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit; using ZB.MOM.WW.ScadaBridge.HealthMonitoring; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services; /// /// Default implementation — a thin pass-through /// to . Default page size is 100 (the /// AuditResultsGrid default for #23 M7). /// /// /// /// #23 M7 (Bundle H follow-up): each query opens its OWN DI scope and resolves a /// fresh — and therefore a fresh /// ScadaBridgeDbContext — rather than sharing the scoped Blazor-circuit /// context. Without this, the Audit Log page's query-string auto-load /// (/audit/log?correlationId=…) races AuditFilterBar.GetAllSitesAsync() /// on the single circuit-scoped ScadaBridgeDbContext, producing EF Core's /// "A second operation was started on this context instance" error. Scope-per-query /// removes the shared state so the two initial loads can run concurrently. This /// mirrors the scope-per-message pattern used by AuditLogIngestActor. /// /// /// A second constructor takes an directly — a /// test seam (mirroring AuditLogIngestActor's dual ctor) so unit tests can /// substitute a stub without standing up a DI container. /// /// public sealed class AuditLogQueryService : IAuditLogQueryService { // M7 Bundle E (T13): trailing window for the Health dashboard's Audit KPI tiles. // Hard-coded here rather than configurable because the requirement // (Component-AuditLog.md §"Health & KPIs") fixes "rows/min over the last hour" // and "% errors over the last hour" as the KPI definition. private static readonly TimeSpan KpiWindow = TimeSpan.FromHours(1); // Audit Log Node filter (Task 15): the distinct-source-nodes lookup powers // the filter dropdown population. A 60s cache keeps rendering the filter // bar cheap — node membership changes (failover, scaling) surface within // a minute, which is acceptable for a filter affordance. private static readonly TimeSpan DistinctSourceNodesTtl = TimeSpan.FromSeconds(60); // 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 IAuditLogRepository? _injectedRepository; private readonly ICentralHealthAggregator _healthAggregator; // Distinct-source-nodes cache. Lock guards the (snapshot, expiry) pair so // a stampede of concurrent filter-bar renders does not turn into N DB hits. private readonly object _sourceNodesLock = new(); private IReadOnlyList? _cachedSourceNodes; private DateTime _cachedSourceNodesExpiryUtc = DateTime.MinValue; /// /// 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 the filter bar uses. /// /// Factory used to open a fresh DI scope per query. /// Central health aggregator for KPI backlog data. public AuditLogQueryService( IServiceScopeFactory scopeFactory, ICentralHealthAggregator healthAggregator) { _scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory)); _healthAggregator = healthAggregator ?? throw new ArgumentNullException(nameof(healthAggregator)); } /// /// Test-seam constructor — injects a repository instance whose lifetime the /// caller owns. Used by unit tests that substitute a stub repository. /// /// The audit log repository instance to use directly. /// Central health aggregator for KPI backlog data. public AuditLogQueryService( IAuditLogRepository repository, ICentralHealthAggregator healthAggregator) { _injectedRepository = repository ?? throw new ArgumentNullException(nameof(repository)); _healthAggregator = healthAggregator ?? throw new ArgumentNullException(nameof(healthAggregator)); } /// public int DefaultPageSize => 100; /// public async Task> QueryAsync( AuditLogQueryFilter filter, AuditLogPaging? paging = null, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(filter); var effective = paging ?? new AuditLogPaging(DefaultPageSize); // Test-seam ctor: use the injected repository directly. if (_injectedRepository is not null) { return await _injectedRepository.QueryAsync(filter, effective, ct); } // Production: a fresh scope (and thus a fresh DbContext) per query so the // page's auto-load never shares the circuit-scoped context. await using var scope = _scopeFactory!.CreateAsyncScope(); var repository = scope.ServiceProvider.GetRequiredService(); return await repository.QueryAsync(filter, effective, ct); } /// public async Task GetKpiSnapshotAsync(CancellationToken ct = default) { // 1. Volume + error counts: aggregate over the trailing 1h window. // BacklogTotal is left at 0 by the repository — we fill it from the // in-memory health aggregator below. Resolved via a per-call scope on // the production path for the same context-isolation reason as // QueryAsync; the test-seam ctor uses the injected repository. AuditLogKpiSnapshot repoSnapshot; if (_injectedRepository is not null) { repoSnapshot = await _injectedRepository.GetKpiSnapshotAsync(KpiWindow, nowUtc: null, ct); } else { await using var scope = _scopeFactory!.CreateAsyncScope(); var repository = scope.ServiceProvider.GetRequiredService(); repoSnapshot = await repository.GetKpiSnapshotAsync(KpiWindow, nowUtc: null, ct); } // 2. Backlog: sum PendingCount across every site's latest report. // Sites that have not yet reported or whose reporter is disabled // leave SiteAuditBacklog null — those contribute zero (a Missing // snapshot is "unknown", not "zero", but the tile is best-effort). long backlog = 0; foreach (var state in _healthAggregator.GetAllSiteStates().Values) { var pending = state.LatestReport?.SiteAuditBacklog?.PendingCount; if (pending is > 0) { backlog += pending.Value; } } return repoSnapshot with { BacklogTotal = backlog }; } /// public async Task> GetExecutionTreeAsync( Guid executionId, CancellationToken ct = default) { // Test-seam ctor: use the injected repository directly. if (_injectedRepository is not null) { return await _injectedRepository.GetExecutionTreeAsync(executionId, ct); } // Production: a fresh scope (and thus a fresh DbContext) per call — the // same context-isolation contract QueryAsync upholds, so the tree // page's auto-load never shares the circuit-scoped context. await using var scope = _scopeFactory!.CreateAsyncScope(); var repository = scope.ServiceProvider.GetRequiredService(); return await repository.GetExecutionTreeAsync(executionId, ct); } /// public async Task> GetDistinctSourceNodesAsync(CancellationToken ct = default) { // Fast path: a fresh cache entry served entirely from memory. lock (_sourceNodesLock) { if (_cachedSourceNodes is not null && DateTime.UtcNow < _cachedSourceNodesExpiryUtc) { return _cachedSourceNodes; } } IReadOnlyList snapshot; if (_injectedRepository is not null) { snapshot = await _injectedRepository.GetDistinctSourceNodesAsync(ct); } else { await using var scope = _scopeFactory!.CreateAsyncScope(); var repository = scope.ServiceProvider.GetRequiredService(); snapshot = await repository.GetDistinctSourceNodesAsync(ct); } // Slow path: replace the cache entry. Concurrent slow paths can race // here — every winner stores a valid snapshot, so the last write wins // and no caller sees stale data. The double-check inside the lock is // deliberately omitted: redundant DB hits during a stampede are rare // (60s TTL) and cheaper than holding the lock across the await. lock (_sourceNodesLock) { _cachedSourceNodes = snapshot; _cachedSourceNodesExpiryUtc = DateTime.UtcNow + DistinctSourceNodesTtl; } return snapshot; } }