fix(ui): AuditLogQueryService uses scope-per-query to avoid DbContext race (#23 M7)

This commit is contained in:
Joseph Doherty
2026-05-20 21:33:38 -04:00
parent 9c955da2e7
commit fac31c6018
5 changed files with 190 additions and 16 deletions

View File

@@ -4,6 +4,7 @@ using ScadaLink.CentralUI.Auth;
using ScadaLink.CentralUI.Components.Shared;
using ScadaLink.CentralUI.ScriptAnalysis;
using ScadaLink.CentralUI.Services;
using ScadaLink.HealthMonitoring;
namespace ScadaLink.CentralUI;
@@ -30,7 +31,16 @@ public static class ServiceCollectionExtensions
// Audit Log (#23 M7-T3): CentralUI facade over IAuditLogRepository so the
// results grid can be tested with a stubbed query source.
services.AddScoped<IAuditLogQueryService, AuditLogQueryService>();
//
// Registered with an explicit factory so the IServiceScopeFactory ctor is
// always chosen — AuditLogQueryService has a second (test-seam) ctor that
// takes IAuditLogRepository directly, and both are constructor-resolvable,
// so default activation would be ambiguous. The scope-factory ctor opens a
// fresh DbContext per query, which is what keeps the page's auto-load from
// racing AuditFilterBar's site enumeration on the shared scoped context.
services.AddScoped<IAuditLogQueryService>(sp => new AuditLogQueryService(
sp.GetRequiredService<IServiceScopeFactory>(),
sp.GetRequiredService<ICentralHealthAggregator>()));
// Audit Log (#23 M7-T14 / Bundle F): server-side streaming CSV export.
// Backs the Audit Log page's Export button via GET /api/centralui/audit/export.

View File

@@ -1,3 +1,4 @@
using Microsoft.Extensions.DependencyInjection;
using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Interfaces.Repositories;
using ScadaLink.Commons.Types;
@@ -11,6 +12,24 @@ namespace ScadaLink.CentralUI.Services;
/// to <see cref="IAuditLogRepository.QueryAsync"/>. Default page size is 100 (the
/// AuditResultsGrid default for #23 M7).
/// </summary>
/// <remarks>
/// <para>
/// #23 M7 (Bundle H follow-up): each query opens its OWN DI scope and resolves a
/// fresh <see cref="IAuditLogRepository"/> — and therefore a fresh
/// <c>ScadaLinkDbContext</c> — rather than sharing the scoped Blazor-circuit
/// context. Without this, the Audit Log page's query-string auto-load
/// (<c>/audit/log?correlationId=…</c>) races <c>AuditFilterBar.GetAllSitesAsync()</c>
/// on the single circuit-scoped <c>ScadaLinkDbContext</c>, 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 <c>AuditLogIngestActor</c>.
/// </para>
/// <para>
/// A second constructor takes an <see cref="IAuditLogRepository"/> directly — a
/// test seam (mirroring <c>AuditLogIngestActor</c>'s dual ctor) so unit tests can
/// substitute a stub without standing up a DI container.
/// </para>
/// </remarks>
public sealed class AuditLogQueryService : IAuditLogQueryService
{
// M7 Bundle E (T13): trailing window for the Health dashboard's Audit KPI tiles.
@@ -19,27 +38,62 @@ public sealed class AuditLogQueryService : IAuditLogQueryService
// and "% errors over the last hour" as the KPI definition.
private static readonly TimeSpan KpiWindow = TimeSpan.FromHours(1);
private readonly IAuditLogRepository _repository;
// 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;
/// <summary>
/// Production constructor — resolves <see cref="IAuditLogRepository"/> from a
/// fresh DI scope on every call so each query gets its own
/// <c>ScadaLinkDbContext</c> and never contends with the circuit-scoped
/// context the filter bar uses.
/// </summary>
public AuditLogQueryService(
IServiceScopeFactory scopeFactory,
ICentralHealthAggregator healthAggregator)
{
_scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory));
_healthAggregator = healthAggregator ?? throw new ArgumentNullException(nameof(healthAggregator));
}
/// <summary>
/// Test-seam constructor — injects a repository instance whose lifetime the
/// caller owns. Used by unit tests that substitute a stub repository.
/// </summary>
public AuditLogQueryService(
IAuditLogRepository repository,
ICentralHealthAggregator healthAggregator)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_injectedRepository = repository ?? throw new ArgumentNullException(nameof(repository));
_healthAggregator = healthAggregator ?? throw new ArgumentNullException(nameof(healthAggregator));
}
public int DefaultPageSize => 100;
public Task<IReadOnlyList<AuditEvent>> QueryAsync(
public async Task<IReadOnlyList<AuditEvent>> QueryAsync(
AuditLogQueryFilter filter,
AuditLogPaging? paging = null,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(filter);
var effective = paging ?? new AuditLogPaging(DefaultPageSize);
return _repository.QueryAsync(filter, effective, ct);
// 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<IAuditLogRepository>();
return await repository.QueryAsync(filter, effective, ct);
}
/// <inheritdoc/>
@@ -47,8 +101,20 @@ public sealed class AuditLogQueryService : IAuditLogQueryService
{
// 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.
var repoSnapshot = await _repository.GetKpiSnapshotAsync(KpiWindow, nowUtc: null, ct);
// 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<IAuditLogRepository>();
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