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

@@ -270,15 +270,18 @@ public class AuditLogPageTests
await Assertions.Expect(page.Locator("[data-test='audit-filter-bar']")).ToBeVisibleAsync();
await Assertions.Expect(page.Locator("[data-test='audit-results-grid']")).ToBeVisibleAsync();
// NOTE (deviation, #23 M7-T16): Bundle D's auto-load (the grid
// populating without an Apply click) currently fails on this drill-in
// path with an EF "A second operation was started on this context
// instance" error — the page-level query-string auto-load races the
// AuditFilterBar's GetAllSitesAsync() on the shared scoped Blazor
// DbContext. This test therefore asserts the drill-in *navigation*
// contract only; auto-load population is intentionally NOT asserted
// here. Filed as a Bundle D follow-up. The Apply-driven query path is
// covered green by FilterNarrowing / DrilldownDrawer tests.
// Bundle D auto-load: the query-string drill-in populates the grid
// WITHOUT an Apply click. The grid resolves the ?correlationId= filter
// on OnInitialized and the seeded row appears.
//
// This was previously blocked by an EF "A second operation was started
// on this context instance" error — the page-level auto-load raced
// AuditFilterBar.GetAllSitesAsync() on the shared scoped Blazor
// DbContext. AuditLogQueryService now opens its own DI scope per query
// (scope-per-query), so the auto-load no longer contends with the
// filter bar's site enumeration and the assertion is restored.
var seededRow = page.Locator($"[data-test='grid-row-{eventId}']");
await Assertions.Expect(seededRow).ToBeVisibleAsync();
}
finally
{

View File

@@ -12,6 +12,7 @@
<PackageReference Include="bunit" />
<PackageReference Include="coverlet.collector" />
<PackageReference Include="Microsoft.AspNetCore.TestHost" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="NSubstitute" />
<PackageReference Include="xunit" />
@@ -28,6 +29,12 @@
<ItemGroup>
<ProjectReference Include="../../src/ScadaLink.CentralUI/ScadaLink.CentralUI.csproj" />
<!--
The DbContext-race regression test (AuditLogQueryServiceTests) exercises a
real ScadaLinkDbContext + AuditLogRepository over SQLite in-memory to prove
scope-per-query isolation. Pulls in the ConfigurationDatabase project.
-->
<ProjectReference Include="../../src/ScadaLink.ConfigurationDatabase/ScadaLink.ConfigurationDatabase.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,3 +1,6 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
using ScadaLink.CentralUI.Services;
using ScadaLink.Commons.Entities.Audit;
@@ -6,6 +9,8 @@ using ScadaLink.Commons.Messages.Health;
using ScadaLink.Commons.Types;
using ScadaLink.Commons.Types.Audit;
using ScadaLink.Commons.Types.Enums;
using ScadaLink.ConfigurationDatabase;
using ScadaLink.ConfigurationDatabase.Repositories;
using ScadaLink.HealthMonitoring;
namespace ScadaLink.CentralUI.Tests.Services;
@@ -134,6 +139,89 @@ public class AuditLogQueryServiceTests
Assert.Equal(4, snapshot.BacklogTotal);
}
// ─────────────────────────────────────────────────────────────────────────
// #23 M7 — DbContext concurrency race regression (Bundle H follow-up)
//
// The drill-in deep link (/audit/log?correlationId=…) triggers an OnInitialized
// auto-load query that races AuditFilterBar.GetAllSitesAsync() on the SAME
// scoped Blazor-circuit ScadaLinkDbContext. EF Core then throws
// "A second operation was started on this context instance before a previous
// operation completed." AuditLogQueryService now opens its OWN DI scope per
// QueryAsync call (scope-per-query) so it never shares the page's scoped
// context — these tests pin that contract.
// ─────────────────────────────────────────────────────────────────────────
[Fact]
public async Task AuditLogQueryService_ConcurrentQueries_DoNotRace()
{
// A real ScadaLinkDbContext (SQLite in-memory) registered as SCOPED with
// the real AuditLogRepository — exactly the shared-scoped-context shape
// that produces the EF race when one context services two operations.
using var connection = new Microsoft.Data.Sqlite.SqliteConnection("DataSource=:memory:");
connection.Open();
var services = new ServiceCollection();
services.AddLogging();
services.AddDbContext<ScadaLinkDbContext>(options =>
options.UseSqlite(connection)
.ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning)));
services.AddScoped<IAuditLogRepository, AuditLogRepository>();
await using var provider = services.BuildServiceProvider();
// Create the schema once on a throwaway scope.
using (var seedScope = provider.CreateScope())
{
var ctx = seedScope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
await ctx.Database.EnsureCreatedAsync();
}
var scopeFactory = provider.GetRequiredService<IServiceScopeFactory>();
var sut = new AuditLogQueryService(scopeFactory, EmptyAggregator());
var filter = new AuditLogQueryFilter(Channel: AuditChannel.ApiOutbound);
// Fire two QueryAsync calls in parallel. With scope-per-query each gets a
// fresh DbContext, so this completes cleanly; with a shared scoped context
// EF throws "A second operation was started on this context instance".
var t1 = sut.QueryAsync(filter);
var t2 = sut.QueryAsync(filter);
var results = await Task.WhenAll(t1, t2);
Assert.All(results, Assert.NotNull);
}
[Fact]
public async Task QueryAsync_OpensFreshScopePerCall_NotSharedAcrossCalls()
{
// Two sequential calls must each resolve the repository from a distinct
// scope — the service must never cache a single repository instance.
var resolvedRepos = new List<IAuditLogRepository>();
var services = new ServiceCollection();
services.AddScoped<IAuditLogRepository>(_ =>
{
var repo = Substitute.For<IAuditLogRepository>();
repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
resolvedRepos.Add(repo);
return repo;
});
await using var provider = services.BuildServiceProvider();
var sut = new AuditLogQueryService(
provider.GetRequiredService<IServiceScopeFactory>(),
EmptyAggregator());
await sut.QueryAsync(new AuditLogQueryFilter());
await sut.QueryAsync(new AuditLogQueryFilter());
// Each QueryAsync opened its own scope → two distinct repo instances.
Assert.Equal(2, resolvedRepos.Count);
Assert.NotSame(resolvedRepos[0], resolvedRepos[1]);
}
private static SiteHealthState StateWithBacklog(string siteId, int? pending)
{
SiteAuditBacklogSnapshot? backlog = pending.HasValue